Compare commits

...

33 Commits

Author SHA1 Message Date
ykcheungab c10e579371 update 3d scafold 2026-09-14 13:55:36 +08:00
ykcheungab 4a482e579f 按"硬件问题、配置不变"收尾:记录里写下实测帧率,探针加逐颗验收模式 - 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
ykcheungab 30ef50814f 探针增加尺寸扫描:区分链路带宽不够与摄像头本身就是 15 fps - What: tools/camera_probe.py 新增 SWEEP_SIZES 与 size_sweep(),在帧率扫描之后对 MJPG@30 依次申请 1920x1080/1280x720/960x540/848x480/640x480 并打印实测速率,提示语写明判断方式:帧越小越快 ⇒ 链路带宽是瓶颈;每个尺寸都一样 ⇒ 是摄像头本身的速率上限。 - Why: 用户实测 MJPG 在 1080p 与 720p 都是 15 fps、YUYV 两档都是 1 fps,两种解释仍然都成立(链路只能扛约 3-4 MB/s,或这颗摄像头在这些格式上就是 15 fps 设备),而处置完全不同;帧率扫描只能区分"申请被砍半"与"固定上限",尺寸扫描才能区分"带宽"与"设备"。 - Verification: compileall 通过;无摄像头时仍返回 1 并给出解释;studio 全量 91 passed(探针的无摄像头用例覆盖 main() 的降级路径)。 - Not in this change: 未改 SDK 采集参数;未做自动降级。 2026-09-14 13:40:04 +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 3db55b2077 新增摄像头能力探针 tools/camera_probe.py,用来把 16.7 fps 归因到格式协商 - What: 新脚本对每个摄像头索引 × 两种后端(DSHOW/MSMF)× 五组请求(MJPG 1920x1080@30、MJPG 1920x1080@auto、MJPG 1280x720@30、YUYV 1920x1080@30、YUYV 1280x720@30)实际打开并连续抓 60 帧,逐行打印"申请 → 驱动协商到的 fourcc/尺寸/帧率 + 实测帧率",这样\"协商到 30\"与\"真的给到 30\"是两个分开的事实;没有摄像头时给出明确说明(含"要关掉 app,两个进程不能同时占用同一个摄像头")并返回 1;--help/--index/--max-index 可用。tests/test_ui_smoke.py 增加 test_the_camera_probe_is_usable_without_a_camera:无摄像头时返回 1 且输出解释、fourcc 编解码往返正确('MJPG' → 'MJPG'、0 → '-')。 - Why: 用户按上一轮读数确认"还是摄像头 16.7",即摄像头自身只发布 16.7。配置申请的是 resolution_raw=1920x1080、fps=30、fourcc 走 CaptureConfig 默认 MJPG、backend=auto;SDK 已在 open 里做了 width/height → fourcc → fps → fourcc 再设一次 + warmup 后校验并在 fourcc 不符时重试一次,但仍存在两种可能:这颗摄像头在 1080p 下就只有 16.67(UVC 常见),或者它在 MJPG 之外落到了别的格式/后端。这两种的处置完全不同(前者要改分辨率/换摄像头,而 1920x1080 是 5x3 瓦片网格的结构参数,改它要重做拼接标定;后者是申请顺序/格式的问题,可以在 SDK 侧修),而在应用里看不出来。因此需要一个不经过应用的独立探针,把驱动真正提供的东西列出来。 - Verification: 本机(无摄像头)运行 --index 9 → 输出 "No camera answered on indices 9..9." 与关闭 app 的提示、退出码 1;--help 正常;_decode(_fourcc_code('MJPG')) == 'MJPG'。studio 全量 91 passed(新增 1 项);ui_check 554 项检查 0 失败。 - Not in this change: 未改 SDK 的采集申请顺序或后端选择(等探针结果:若显示 MJPG+1080p 下驱动只给 16.67,则是硬件边界;若显示申请 MJPG 却协商成 YUYV,再改 SDK);未做分辨率自动降级。 2026-09-14 13:30:21 +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 0d8b79b759 app 侧滤波接口改为驱动 SDK 并读回生效值 - What: shared/force/filters.py 从"自己滤一遍"改成 app 的滤波控制面,只做三件事:(1) FilterSettings 改用 SDK 的键与"按值开关"语义(flow_ema_alpha / median_window / lowpass_hz / lowpass_order / sample_rate_hz / show_unfiltered / preset;α≥1、窗口≤1、截止≤0 各自表示关),normalised() 把不可用值一律落成"关"而不是落成会滤波的默认值,并新增 problems(sample_rate_hz) 指出"截止 ≥ Nyquist、设备根本不会滤"这类请求;(2) apply_to(sensor) 就是一次 Sensor.configure(...),用 CONFIGURE_PARAMETERS 一处维护"状态键 → configure 形参"的映射(低通那一对在配置/状态里叫 force_lowpass_*、在调用里叫 lowpass_*,此前正是这里写错导致支持性判断把所有键都判成不支持),并返回该 SDK 支持不了的键而不是默默丢掉——老 SDK(0.5.2 之前)没有 flow_ema_alpha/中值,其光流 EMA 也关不掉,必须让人看见;(3) from_filters_state(state) 读回设备**实际**在跑的值(界面与记录都以此为准),metadata(sensor_state) 产出 session.json/CSV 头的 filters 块,measurement 组与 readout 组严格分开、带 describe 与 source(有设备时为 sensor.filters_state,只有请求时为 app.FilterSettings,两者不可混认)。新增 describe_state()/settings_from_mapping();ForceFilterChain 降级为"参考实现",只为 tests/test_force_filters.py 的等价性断言存在(app 不再调用它),并删掉随之无用的 _odd()。force/__init__.py 导出 describe_state。tests/test_force_filters.py 重写为 19 项:按值开关、不可用值=关、Nyquist 预警、一次 configure 调用且键名正确、老 SDK 缺键被上报而不是忽略、显示开关绝不发给设备、读回生效值与 sample_rate 往返、metadata 两组分离与来源标注、空的 preferences 映射等于"什么都不滤"、参考链仍与 SDK 滤波器逐位一致。 - Why: 用户要求"统一采集滤波和显示滤波、显示的和记录的必须一致、保留滤波前数据两个 buffer",并决定整条链留在 SDK、app 通过 Sensor.configure 驱动、filters.py 保留为 wrapper 与 CSV 元数据出口。上一版的 FilterSettings 用 median_enabled/lowpass_enabled 布尔 + app 自己实现滤波,既与 SDK 的"按值开关"两套词汇,也会让同一份数据在界面与 CSV 之间出现第二条滤波路径。 - Verification: pytest shared/tests/test_force_filters.py → 19 passed(含与 orisys.signal_processing.LowpassFilter 的逐位等价断言);shared 全量 153 passed;SDK 已升级到 0.5.2(editable),studio 85 项测试与 ui_check 554 项检查在其上全绿。 - Not in this change: sdk_worker/finger_worker 仍在硬编码 lowpass_cutoff_hz=10.0(下一步改为 apply_to)、曲线图仍在显示端做 filtfilt、齿轮对话框与 plot_* preferences、CSV 的 17 列与未滤波 buffer、session.json 写入 filters 块都还没接。 2026-09-14 10:56:04 +08:00
ykcheungab 65dafdc84d 补两条滤波引擎测试:与 SDK 滤波逐点等价、prefs 往返 - What: test_force_filters.py 新增 test_the_chain_reproduces_the_sdk_filter_exactly(同一段 400 点输入分别喂 orisys.signal_processing.LowpassFilter 与我们 6 通道链的前三通道,断言 np.array_equal,实测 max|差|=0.0,SDK 不在时装作通过)与 test_settings_round_trip_through_a_preferences_mapping(median_window 7、lowpass 12.5 Hz、show_unfiltered 往返,describe() 等于 "median(7) + lowpass(12.5Hz,order2)",空映射取默认弱通)。 - Why: 决定"只留一条低通、放 SDK 里、app 通过 configure 驱动"之后,必须证明"换实现不移动任何一个数"是保证而不是希望;这两条把该保证钉进套件,并让 FilterSettings 成为 prefs/session.json 的唯一真值对象。 - Verification: drive_shared.py 142 项 0 失败(新增 1 项,modify 1 项)、ui_check.py 554 项、test_ui_smoke.py 71 项、locale_studio.py 262 键齐全,全部通过。 - Not in this change: 采集路径未动(SDK 低通仍由 app 硬编码 10 Hz 打开),CSV 仍 11 列;下一步见 .repro/filter_plan.md:SDK 侧 A(配置自描述、去覆盖式总开关)→ SDK 侧 B(filters_state() + force_triplets() 未滤波出口、指纹按 measurement/readout 分组)→ 升级 SDK 进 venv → app 侧 wrapper+齿轮 → 两个 buffer 与 CSV 17 列。 2026-09-14 10:20:33 +08:00
ykcheungab 2fd2891a47 新增单链力滤波引擎(中值 + 因果低通),为"显示=记录"做准备 - What: shared/src/orisys_app_shared/force/filters.py 新增 FilterSettings(中值开关/窗口、低通开关/截止/阶数、采样率、show_unfiltered,窗口强制奇数、<3 自动关闭、空设置直通)与 ForceFilterChain:6 通道(3 原始计数 + 3 牛顿值)、先中值后低通、两级均因果且逐样本、每通道独立 IIR 状态,reset(first) 用稳态初值启动以避免会话开头的假瞬态;settings_from_mapping 支持从 prefs/metadata 构造,describe() 给出一行摘要写进录制元数据;force/__init__ 导出这三个名字。新增 shared/tests/test_force_filters.py(7 例:直通、中值去单点尖峰但不削持续变化、窗口奇数化与下限、阶跃无起始瞬态、通道状态独立、reset 清状态、prefs 往返),并加入 drive_shared 的模块表。 - Why: 用户问"滤波在哪里调",查清现状是两处固化 10 Hz 低通(SDK 侧影响记录、显示侧再用 filtfilt 二次滤波导致显示≠记录)且 median 完全不存在;用户要求把采集滤波与显示滤波统一成一条链、显示值必须等于记录值、并保留滤波前数据(另开一个 buffer,且写进 forces.csv)。本轮先落地这条链并覆盖测试。 - Verification: drive_shared.py 141 项 0 失败(含新增 7 项)、ui_check.py 554 项 0 失败、test_ui_smoke.py 71 项 0 失败、locale_studio.py 262 键齐全。 - Not in this change: 采集路径未改动(SDK 低通仍是 10 Hz、forces.csv 仍是 11 列)——把带状态的滤波器半途接进记录会让数据只滤一半,因此下一步一次性完成:关 SDK 侧滤波、worker 应用链并让 packet 同时携带滤波前后两套值、forces.csv 追加 6 列未滤波读数(11→17、RECORD_FORMAT_VERSION+1)、SessionStore 第二 buffer、去掉显示端 filtfilt、曲线表头新增齿轮按钮并扩展曲线设置对话框(中值/低通/显示未滤波,运行中禁用滤波控件)。完整清单见 .repro/filter_plan.md。 2026-09-14 09:57:03 +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
ykcheungab f501fa93f3 subject: fix calibration cancel, unit mixing, FN clamping, export/apply and depth scale 2026-09-13 12:35:05 +08:00
ykcheungab db228b6f57 subject: store the whole session and drive calibration from the acquisition stream 2026-09-13 12:34:47 +08:00
ykcheungab f16d2b3d84 subject: add packet validity flags and a record-channel sink fan-out 2026-09-13 12:34:19 +08:00
ykcheungab 85d700bc06 fix stop button disabled during connection 2026-09-12 20:27:34 +08:00
ykcheungab 904c27125f changed customer to studio 2026-09-12 14:03:00 +08:00
ykcheungab a2db648560 dsh ui improve: add brand bar 2026-09-12 13:51:05 +08:00
ykcheungab 803a08fc78 dsh UI improvement 2026-09-12 13:26:39 +08:00
ykcheungab 705eaabcca update to match calibration data handling 2026-09-10 21:50:36 +08:00
116 changed files with 28218 additions and 2707 deletions
+8 -1
View File
@@ -15,7 +15,14 @@ venv/
**/outputs/**
!**/.gitkeep
**/preference.json
apps/customer/preference.json
apps/studio/preference.json
# Tooling scratch (local pip/pytest caches)
.piptmp/
.pytestlib/
.pytest_cache/
shared/tests/_scratch/
shared/tests/_scratch_*/
# IDE / OS
.idea/
+61 -11
View File
@@ -4,31 +4,81 @@ Desktop applications that consume the Orisys SDK.
| App | Status |
|-----|--------|
| `apps/customer` | Scaffold shell (theme / locale / prefs / logging) |
| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) |
| `apps/internal` | Stub for future calibration & automated testing |
## Layout
- `shared/` — common paths, prefs, theme, i18n, logging
- `apps/customer/config/` — locales, default prefs, editable `sensors/` copies
- `apps/customer/outputs/` — generated logs and calibration exports (gitignored)
- `build/` — Windows PyInstaller packaging (`build/customer/build.ps1`)
- `apps/studio/config/` — locales, default prefs, editable `sensors/` copies
- `apps/studio/outputs/` — generated logs and calibration exports (gitignored)
- `build/` — Windows PyInstaller packaging (`build/studio/build.ps1`)
## Run (customer shell)
## Run (studio shell)
```powershell
cd D:\repo\orisys-app
python -m pip install -e .\shared -e .\apps\customer
python .\apps\customer\run.py
python -m pip install -e .\shared -e .\apps\studio
python .\apps\studio\run.py
```
Or without install:
```powershell
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
python D:\repo\orisys-app\apps\customer\run.py
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src"
python D:\repo\orisys-app\apps\studio\run.py
```
### Local `orisys` SDK
`orisys_app_shared.force` imports `orisys.calibration` at module scope, so the
studio app cannot start against an SDK build that predates that module
(the published 0.5.2 wheel does not include it). When working from a repo
checkout, install the SDK from source instead of from an index:
```powershell
python -m pip install .\..\orisys-sdk # same checkout that built this app
```
If a pip build is blocked by the environment, the equivalent manual install is
to copy `orisys-sdk/src/orisys/**/*.py` over `site-packages/orisys/` and delete
the stale `__init__.cp310-win_amd64.pyd` (an extension module shadows the
package `__init__.py`, which hides sibling modules such as `calibration.py`).
### Verifying UI changes
Both tools render the real main window with the offscreen Qt platform — no
display, camera or sensor hardware required:
```powershell
python .\apps\studio\tools\ui_check.py # layout/theme assertions
python .\apps\studio\tools\ui_snapshot.py --theme light --demo `
--sensor finger --panels cal,3d --size 1600x900 --out shot.png
```
`ui_check.py` needs only the runtime dependencies. The `pytest` suite in
`shared/tests` and `apps/studio/tests` covers the same ground plus locale and
preference-schema parity:
```powershell
pip install pytest
python -m pytest
```
On Windows the offscreen platform finds no fonts unless `QT_QPA_FONTDIR` points
at `C:\Windows\Fonts`; both tools set it automatically.
`ui_check.py` also runs the palette rules (WCAG contrast, colour separation,
ramp monotonicity) that guard the brand tokens; see
[`apps/studio/docs/ui-palette.md`](apps/studio/docs/ui-palette.md) for the
colour system, its relationship to the UI design spec, and every deliberate
deviation from it.
`ui_check.py` includes a `record channel` section as well; see
[`apps/studio/docs/record-channel.md`](apps/studio/docs/record-channel.md) for
why recording and calibration sampling no longer hang off the UI refresh timer,
and for the data-reliability fixes that came with it.
## Branding
Window / EXE icon: `shared/resources/branding/logo.png`
@@ -38,9 +88,9 @@ Dark mode is the default. Logos currently sit on a black tile; a transparent (or
## Versioning
- **Customer app version** (`orisys_customer.__version__`) — shown in Help → About and used for releases.
- **Studio app version** (`orisys_studio.__version__`) — shown in Help → About and used for releases.
- **Shared package version** (`orisys_app_shared.__version__`) — internal library version.
- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Customer declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch.
- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Studio declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch.
## Deferred
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

-116
View File
@@ -1,116 +0,0 @@
{
"app": {
"title": "Orisys App",
"about_title": "About",
"about_body": "Orisys customer desktop application\nVersion: {app_version}",
"scaffold_hint": "Scaffold shell — acquisition coming later"
},
"menu": {
"file": "File",
"open_video": "Open video…",
"reveal_config": "Reveal sensor config…",
"view": "View",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"language": "Language",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "Calibration panel",
"finger_3d": "Finger 3D view",
"show_fps": "Show FPS",
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
"help": "Help",
"about": "About"
},
"dialog": {
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"reset_defaults": "Reset to defaults",
"ok": "OK",
"cancel": "Cancel"
},
"toolbar": {
"source": "Source",
"source_camera": "Camera",
"source_video": "Video file",
"search_cameras": "Search",
"cam_id": "Cam ID",
"browse_video": "Browse…",
"video_placeholder": "No video selected",
"preview": "Preview",
"preview_stop": "Stop preview",
"run": "Run",
"stop": "Stop",
"reset": "Reset",
"units": "Units",
"units_newton": "Newton",
"units_raw": "Raw"
},
"sensor": {
"type": "Sensor type",
"config": "Config",
"thumb_placeholder": "Thumbnail"
},
"panels": {
"arrow": "2D arrows",
"view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map",
"view_mode_tip": "Image display mode while running",
"plots": "Force Charts",
"cal": "Calibration",
"mesh3d": "3D mesh",
"mesh3d_hint": "Finger 3D view placeholder — Open3D mesh coming later"
},
"cal": {
"weight": "Weight (N)",
"duration": "Record duration",
"record": "Record",
"delete_row": "Delete row",
"clear_channel": "Clear channel",
"clear_all": "Clear all",
"export": "Export…",
"sn": "SN",
"sn_placeholder": "Serial number (optional)",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
"col_weight": "Weight (N)",
"col_raw": "Raw mean",
"col_n": "n",
"col_std": "σ",
"col_actions": "",
"recording": "Recording calibration samples…",
"record_ok": "Recorded weight={weight} raw={raw}",
"record_failed_few": "Recording failed — too few samples",
"export_ok": "Exported {path}",
"export_local": "Also wrote {path}",
"export_warnings": "Skipped: {detail}",
"export_failed": "Export failed: {error}"
},
"status": {
"ready": "Ready",
"camera_none": "No cameras found",
"camera_found": "Found {count} camera(s): {names}",
"preview_started": "Preview started (camera {camera_id})",
"preview_stopped": "Preview stopped",
"preview_failed": "Preview failed: {error}",
"connecting": "Connecting…",
"run_started": "Running ({source})",
"run_stopped": "Stopped",
"run_failed": "Run failed: {error}",
"video_ended": "Video ended",
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
"units_raw_fallback": "Calibration unavailable — showing raw units",
"reveal_failed": "Could not reveal config: {error}"
}
}
-116
View File
@@ -1,116 +0,0 @@
{
"app": {
"title": "Orisys 应用",
"about_title": "关于",
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
},
"menu": {
"file": "文件",
"open_video": "打开视频…",
"reveal_config": "在资源管理器中显示配置…",
"view": "视图",
"theme": "主题",
"theme_dark": "深色",
"theme_light": "浅色",
"language": "语言",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "标定面板",
"finger_3d": "手指 3D 视图",
"show_fps": "显示 FPS",
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
"help": "帮助",
"about": "关于"
},
"dialog": {
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"reset_defaults": "恢复默认",
"ok": "确定",
"cancel": "取消"
},
"toolbar": {
"source": "来源",
"source_camera": "摄像机",
"source_video": "视频文件",
"search_cameras": "搜索",
"cam_id": "摄像机",
"browse_video": "浏览…",
"video_placeholder": "未选择视频",
"preview": "预览",
"preview_stop": "停止预览",
"run": "运行",
"stop": "停止",
"reset": "重置",
"units": "单位",
"units_newton": "牛顿",
"units_raw": "原始"
},
"sensor": {
"type": "传感器类型",
"config": "配置",
"thumb_placeholder": "缩略图"
},
"panels": {
"arrow": "形变箭头",
"view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图",
"view_mode_tip": "运行时的图像显示模式",
"plots": "力曲线图",
"cal": "标定",
"mesh3d": "三维网格",
"mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入"
},
"cal": {
"weight": "砝码重量 (N)",
"duration": "采集时长",
"record": "记录",
"delete_row": "删除行",
"clear_channel": "清空当前通道",
"clear_all": "清空全部",
"export": "导出…",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
"col_weight": "重量 (N)",
"col_raw": "原始均值",
"col_n": "n",
"col_std": "σ",
"col_actions": "",
"recording": "正在采集标定样本…",
"record_ok": "已记录 weight={weight} raw={raw}",
"record_failed_few": "采集失败 — 样本过少",
"export_ok": "已导出 {path}",
"export_local": "同时写入 {path}",
"export_warnings": "跳过: {detail}",
"export_failed": "导出失败: {error}"
},
"status": {
"ready": "就绪",
"camera_none": "未发现摄像机",
"camera_found": "发现 {count} 个摄像机: {names}",
"preview_started": "预览已启动(摄像机 {camera_id})",
"preview_stopped": "预览已停止",
"preview_failed": "预览失败: {error}",
"connecting": "正在连接…",
"run_started": "运行中({source})",
"run_stopped": "已停止",
"run_failed": "启动失败: {error}",
"video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
"units_raw_fallback": "无标定数据 — 显示原始单位",
"reveal_failed": "无法打开配置位置: {error}"
}
}
@@ -1,865 +0,0 @@
"""Main window shell for the customer app."""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
from typing import Any
import numpy as np
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QImage
from PyQt5.QtWidgets import (
QAction,
QActionGroup,
QApplication,
QFileDialog,
QHBoxLayout,
QMainWindow,
QMessageBox,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.theme import build_qss, color
from orisys_customer.ui.about_dialog import AboutDialog
from orisys_customer.ui.arrow_panel import ArrowPanel
from orisys_customer.ui.cal_panel import CalPanel
from orisys_customer.ui.force_plots import ForcePlotsPanel
from orisys_customer.ui.mesh3d_panel import Mesh3dPanel
from orisys_customer.ui.plot_settings_dialog import (
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_WINDOW_SEC,
PlotSettingsDialog,
)
from orisys_customer.ui.sensor_picker import SensorPickerWidget
from orisys_customer.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
rgb = frame[:, :, ::-1].copy()
h, w, ch = rgb.shape
bytes_per_line = ch * w
return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy()
def _reveal_in_explorer(path: Path) -> None:
path = path.resolve()
if sys.platform == "win32":
subprocess.run(["explorer", "/select,", str(path)], check=False)
return
if sys.platform == "darwin":
subprocess.run(["open", "-R", str(path)], check=False)
return
folder = path.parent if path.is_file() else path
subprocess.run(["xdg-open", str(folder)], check=False)
class CustomerMainWindow(QMainWindow):
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
super().__init__()
self.prefs: dict[str, Any] = dict(prefs or {})
self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN")
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
self._show_fps = bool(self.prefs.get("show_fps", False))
self._session_phase = "idle"
self._running = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
self._preview_timer.timeout.connect(self._on_preview_tick)
self._run_timer = QTimer(self)
self._run_timer.setInterval(16)
self._run_timer.timeout.connect(self._on_run_tick)
self._box_worker = None
self._finger_worker = None
self._active_worker = None
self._active_sensor_type = "box"
self._last_session_error = ""
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self._run_source_label = ""
icon = QIcon(str(logo_path()))
if not icon.isNull():
self.setWindowIcon(icon)
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(8)
top = QHBoxLayout()
self.toolbar = ToolbarWidget()
self.sensor_picker = SensorPickerWidget()
top.addWidget(self.toolbar, 2)
top.addWidget(self.sensor_picker, 1)
root.addLayout(top)
self.content_splitter = QSplitter(Qt.Horizontal)
self.mesh3d_panel = Mesh3dPanel()
self.arrow_panel = ArrowPanel()
self.force_plots = ForcePlotsPanel(theme=self._theme)
self.cal_panel = CalPanel()
self.content_splitter.addWidget(self.mesh3d_panel)
self.content_splitter.addWidget(self.arrow_panel)
self.content_splitter.addWidget(self.force_plots)
self.content_splitter.addWidget(self.cal_panel)
root.addWidget(self.content_splitter, 1)
self.setStatusBar(QStatusBar())
self.cal_panel.status_message.connect(self.statusBar().showMessage)
self._build_menus()
self._wire_toolbar()
self._wire_sensor_picker()
self._restore_prefs_into_ui()
self._apply_panel_visibility()
self._update_run_enabled()
self.cal_panel.set_meta_context(
sensor_type=self.sensor_picker.sensor_type(),
config_ref=self._resolve_config_ref(),
)
self.retranslate()
self._restore_geometry()
self.resize(1280, 720)
@property
def _preview_open(self) -> bool:
return self._preview is not None and bool(self._preview.is_open)
def _get_preview(self):
if self._preview is None:
from orisys_app_shared.camera import RawPreviewSession
self._preview = RawPreviewSession()
return self._preview
def _ensure_workers(self) -> None:
if self._box_worker is not None and self._finger_worker is not None:
return
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
if self._box_worker is None:
self._box_worker = SdkAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
if self._finger_worker is None:
self._finger_worker = FingerAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
def _wire_toolbar(self) -> None:
self.toolbar.search_clicked.connect(self._on_search_cameras)
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed)
self.toolbar.browse_video_clicked.connect(self._on_browse_video)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self._on_units_changed)
def _wire_sensor_picker(self) -> None:
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
self.sensor_picker.config_changed.connect(self._on_config_changed)
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
def _restore_prefs_into_ui(self) -> None:
raw_id = str(self.prefs.get("camera_id") or "0").strip()
try:
cam_id = int(raw_id)
except ValueError:
cam_id = 0
self.toolbar.set_camera_id(cam_id)
self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera"))
self.toolbar.set_video_path(str(self.prefs.get("video_path") or ""))
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
self.arrow_panel.set_fps_visible(self._show_fps)
self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90))
self._apply_plot_prefs_to_panel()
sensor_type = str(self.prefs.get("sensor_type") or "box")
config_name = str(self.prefs.get("config_name") or "")
self.sensor_picker.set_sensor_type(sensor_type)
if config_name:
self.sensor_picker.set_config_name(config_name)
else:
self.prefs["config_name"] = self.sensor_picker.config_name()
self._apply_arrow_rotation_for_sensor()
def _build_menus(self) -> None:
menu_bar = self.menuBar()
self.file_menu = menu_bar.addMenu("")
self.act_open_video = QAction(self)
self.act_open_video.triggered.connect(self._on_browse_video)
self.file_menu.addAction(self.act_open_video)
self.act_reveal_config = QAction(self)
self.act_reveal_config.triggered.connect(self._on_reveal_config)
self.file_menu.addAction(self.act_reveal_config)
self.view_menu = menu_bar.addMenu("")
self.act_cal_panel = QAction(self)
self.act_cal_panel.setCheckable(True)
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
self.view_menu.addAction(self.act_cal_panel)
self.act_finger_3d = QAction(self)
self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(self._show_finger_3d)
self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled)
self.view_menu.addAction(self.act_finger_3d)
self.act_show_fps = QAction(self)
self.act_show_fps.setCheckable(True)
self.act_show_fps.setChecked(self._show_fps)
self.act_show_fps.toggled.connect(self._on_show_fps_toggled)
self.view_menu.addAction(self.act_show_fps)
self.act_plot_settings = QAction(self)
self.act_plot_settings.triggered.connect(self._on_plot_settings)
self.view_menu.addAction(self.act_plot_settings)
self.act_rotate_arrows = QAction(self)
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
self.view_menu.addAction(self.act_rotate_arrows)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
self.theme_group = QActionGroup(self)
self.theme_group.setExclusive(True)
self.act_theme_dark = QAction(self)
self.act_theme_dark.setCheckable(True)
self.act_theme_light = QAction(self)
self.act_theme_light.setCheckable(True)
self.theme_group.addAction(self.act_theme_dark)
self.theme_group.addAction(self.act_theme_light)
self.theme_menu.addAction(self.act_theme_dark)
self.theme_menu.addAction(self.act_theme_light)
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
self.lang_menu = self.view_menu.addMenu("")
self.lang_group = QActionGroup(self)
self.lang_group.setExclusive(True)
self.act_lang_zh = QAction(self)
self.act_lang_zh.setCheckable(True)
self.act_lang_en = QAction(self)
self.act_lang_en.setCheckable(True)
self.lang_group.addAction(self.act_lang_zh)
self.lang_group.addAction(self.act_lang_en)
self.lang_menu.addAction(self.act_lang_zh)
self.lang_menu.addAction(self.act_lang_en)
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
self.help_menu = menu_bar.addMenu("")
self.act_about = QAction(self)
self.act_about.triggered.connect(self._show_about)
self.help_menu.addAction(self.act_about)
self._sync_menu_checks()
def _sync_menu_checks(self) -> None:
self.act_theme_dark.setChecked(self._theme != "light")
self.act_theme_light.setChecked(self._theme == "light")
self.act_lang_zh.setChecked(self._language.startswith("zh"))
self.act_lang_en.setChecked(not self._language.startswith("zh"))
self.act_cal_panel.blockSignals(True)
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.blockSignals(False)
is_finger = self.sensor_picker.sensor_type() == "finger"
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
self.act_finger_3d.blockSignals(False)
self._apply_arrow_rotation_for_sensor()
def _apply_panel_visibility(self) -> None:
is_finger = self.sensor_picker.sensor_type() == "finger"
show_3d = is_finger and bool(self._show_finger_3d)
self.mesh3d_panel.setVisible(show_3d)
self.cal_panel.setVisible(self._show_cal)
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d)
self.act_finger_3d.blockSignals(False)
for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel:
self.content_splitter.setStretchFactor(i, 3 if show_3d else 0)
elif widget is self.arrow_panel:
self.content_splitter.setStretchFactor(i, 3)
elif widget is self.force_plots:
self.content_splitter.setStretchFactor(i, 3)
elif widget is self.cal_panel:
self.content_splitter.setStretchFactor(i, 2 if self._show_cal else 0)
def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True
if self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
source_ok = False
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
if not self._running:
self.toolbar._refresh_run_label()
def retranslate(self) -> None:
self.setWindowTitle(t("app.title"))
self.file_menu.setTitle(t("menu.file"))
self.act_open_video.setText(t("menu.open_video"))
self.act_reveal_config.setText(t("menu.reveal_config"))
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
self.lang_menu.setTitle(t("menu.language"))
self.act_lang_zh.setText(t("menu.lang_zh"))
self.act_lang_en.setText(t("menu.lang_en"))
self.help_menu.setTitle(t("menu.help"))
self.act_about.setText(t("menu.about"))
self.toolbar.retranslate()
self.sensor_picker.retranslate()
self.mesh3d_panel.retranslate()
self.arrow_panel.retranslate()
self.force_plots.retranslate()
self.cal_panel.retranslate()
if not self._preview_open and not self._running:
self.statusBar().showMessage(t("status.ready"))
self._update_run_enabled()
def _set_theme(self, theme: str) -> None:
self._theme = theme
self.prefs["theme"] = theme
app = QApplication.instance()
if app is not None:
app.setStyleSheet(build_qss(theme))
self.force_plots.set_theme(theme)
self._sync_menu_checks()
self._persist_prefs()
def _set_language(self, language: str) -> None:
self._language = language
self.prefs["language"] = language
set_locale(language)
self.retranslate()
self._sync_menu_checks()
self._persist_prefs()
def _show_about(self) -> None:
AboutDialog(self).exec_()
def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value))
self._persist_prefs()
def _on_source_mode_changed(self, mode: str) -> None:
self.prefs["source_mode"] = mode
self._persist_prefs()
self._update_run_enabled()
def _on_units_changed(self, units: str) -> None:
self.prefs["units"] = units
self.force_plots.set_fixed_newton(str(units) == "newton")
self._persist_prefs()
def _on_image_view_mode_changed(self, mode: str) -> None:
self.prefs["image_view_mode"] = str(mode)
self._last_rendered_view_mode = None
self._persist_prefs()
if self._running:
if self._active_worker is not None:
self._render_run_image(self._active_worker.latest())
def _on_plot_settings(self) -> None:
dialog = PlotSettingsDialog(
self,
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
units=self.toolbar.units(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max = dialog.values()
self.prefs["plot_window_sec"] = window_sec
self.prefs["plot_fn_max"] = fn_max
self.prefs["plot_shear_max"] = shear_max
self.force_plots.set_window_sec(window_sec)
self.force_plots.set_axis_limits(fn_max, shear_max)
self._last_plot_packet_key = None
self._persist_prefs()
def _apply_plot_prefs_to_panel(self) -> None:
self.force_plots.set_window_sec(
float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC)
)
self.force_plots.set_axis_limits(
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
)
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
def _on_cal_panel_toggled(self, visible: bool) -> None:
self._show_cal = bool(visible)
self.prefs["show_cal_panel"] = self._show_cal
self._apply_panel_visibility()
self._persist_prefs()
def _on_finger_3d_toggled(self, visible: bool) -> None:
if self.sensor_picker.sensor_type() != "finger":
return
self._show_finger_3d = bool(visible)
self.prefs["show_finger_3d"] = self._show_finger_3d
self._apply_panel_visibility()
self._persist_prefs()
def _on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible)
self.prefs["show_fps"] = self._show_fps
self.arrow_panel.set_fps_visible(self._show_fps)
if not self._show_fps:
self.arrow_panel.set_fps(None)
self._persist_prefs()
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
self.cal_panel.set_meta_context(
sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(),
)
self._apply_panel_visibility()
self._apply_arrow_rotation_for_sensor()
self._update_run_enabled()
self._persist_prefs()
def _apply_arrow_rotation_for_sensor(self) -> None:
enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES
self.arrow_panel.set_rotation_enabled(enabled)
self.act_rotate_arrows.setEnabled(enabled)
def _on_rotate_arrows(self) -> None:
if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES:
return
deg = self.arrow_panel.rotate_90_ccw()
self.prefs["arrow_rotation_deg"] = deg
self._persist_prefs()
def _on_config_changed(self, config_name: str) -> None:
self.prefs["config_name"] = str(config_name)
self._persist_prefs()
def _on_browse_video(self) -> None:
if self._running or self._preview_open:
return
start = self.toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName(
self,
t("menu.open_video"),
start,
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
)
if not path:
return
self.toolbar.set_source_mode("video")
self.toolbar.set_video_path(path)
self.prefs["source_mode"] = "video"
self.prefs["video_path"] = path
self._persist_prefs()
self._update_run_enabled()
def _on_reveal_config(self) -> None:
sensor_type = self.sensor_picker.sensor_type()
path = sensor_json_path(sensor_type)
if path is None:
cfg = self.sensor_picker.config_name()
candidate = Path(cfg)
path = candidate if candidate.is_file() else None
if path is None:
self.statusBar().showMessage(t("status.config_missing", type=sensor_type))
return
try:
_reveal_in_explorer(path)
except Exception as exc:
self.statusBar().showMessage(t("status.reveal_failed", error=str(exc)))
def _resolve_config_ref(self) -> str | None:
sensor_type = self.sensor_picker.sensor_type()
local = sensor_json_path(sensor_type)
if local is not None:
return str(local)
cfg = self.sensor_picker.config_name()
if cfg and Path(cfg).is_file():
return cfg
if sensor_type in RUNNABLE_TYPES:
return sensor_type
return None
def _on_search_cameras(self) -> None:
if self._preview_open or self._running:
return
from orisys_app_shared.camera import list_cameras, pick_default_camera
preferred = str(self.toolbar.camera_id())
self.statusBar().showMessage(t("toolbar.search_cameras") + "…")
QApplication.processEvents()
cameras = list_cameras()
if not cameras:
self.statusBar().showMessage(t("status.camera_none"))
return
names = ", ".join(cam.label() for cam in cameras)
self.statusBar().showMessage(
t("status.camera_found", count=len(cameras), names=names)
)
selected = pick_default_camera(cameras, preferred)
if selected is not None:
self.toolbar.set_camera_id(int(selected))
self.prefs["camera_id"] = str(selected)
self._persist_prefs()
def _on_preview_toggled(self, checked: bool) -> None:
if checked:
self._start_preview()
else:
self._stop_preview(announce=True)
def _start_preview(self) -> None:
if self._running or self.toolbar.source_mode() != "camera":
self.toolbar.set_preview_checked(False)
return
camera_id = self.toolbar.camera_id()
try:
self._get_preview().open(camera_id)
except Exception as exc:
msg = t("status.preview_failed", error=str(exc))
self.statusBar().showMessage(msg)
self.toolbar.set_preview_checked(False)
self.arrow_panel.clear_preview(msg)
return
self.toolbar.set_source_controls_enabled(False)
self.toolbar.btn_preview.setEnabled(True)
self._update_run_enabled()
self._preview_timer.start()
self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id))
def _stop_preview(self, *, announce: bool) -> None:
self._preview_timer.stop()
if self._preview is not None:
self._preview.close()
if not self._running:
self.toolbar.set_source_controls_enabled(True)
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
self.toolbar.set_preview_checked(False)
self._update_run_enabled()
if announce:
self.statusBar().showMessage(t("status.preview_stopped"))
def _on_preview_tick(self) -> None:
if self._preview is None:
return
frame = self._preview.read()
if frame is None:
self._stop_preview(announce=True)
self.statusBar().showMessage(t("status.preview_failed", error="no frame"))
return
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _on_run_toggled(self, checked: bool) -> None:
if checked:
self._start_run()
else:
self._stop_run(reason="user_stop")
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
sensor_type = self.sensor_picker.sensor_type()
if sensor_type not in RUNNABLE_TYPES:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.finger_run_unavailable"))
return
ok, detail = orisys_importable()
if not ok:
self.toolbar.set_run_checked(False)
QMessageBox.warning(self, t("app.title"), detail)
return
config_ref = self._resolve_config_ref()
if not config_ref:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(
t("status.config_missing", type=sensor_type)
)
return
source = self.toolbar.active_source()
if source is None:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.video_required"))
return
if self._preview_open:
self._stop_preview(announce=False)
self.force_plots.reset()
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
self._last_session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = sensor_type
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
)
try:
self._active_worker.start(source, config_ref=config_ref)
except Exception as exc:
report_error(exc, message="Run failed")
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
self._running = True
self._session_phase = "connecting"
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
self.sensor_picker.type_combo.setEnabled(False)
self.sensor_picker.config_combo.setEnabled(False)
self.toolbar.set_reset_enabled(False)
self._run_timer.start()
self.statusBar().showMessage(t("status.connecting"))
logger.info(
"Connecting type=%s source=%s config=%s",
sensor_type,
source,
config_ref,
)
def _stop_run(self, *, reason: str = "user_stop") -> None:
self._run_timer.stop()
if self._active_worker is not None:
self._active_worker.stop()
self._running = False
self._session_phase = "idle"
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.type_combo.setEnabled(True)
self.sensor_picker.config_combo.setEnabled(True)
self._update_run_enabled()
keep_frame = reason in ("ended", "disconnected")
if not keep_frame:
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
else:
self.arrow_panel.set_fps(None)
if reason == "ended":
self.statusBar().showMessage(t("status.video_ended"))
elif reason == "disconnected":
self.statusBar().showMessage(t("status.camera_disconnected"))
elif reason == "failed":
pass # caller sets status
elif reason == "user_stop":
self.statusBar().showMessage(t("status.run_stopped"))
else:
self.statusBar().showMessage(t("status.run_stopped"))
def _on_reset(self) -> None:
if self._session_phase != "running" or self._active_worker is None:
return
try:
self._active_worker.reset()
self.force_plots.reset()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_ok"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_worker_error(self, message: str) -> None:
self._last_session_error = message
logger.error("Acquisition error: %s", message)
def _on_worker_status(self, message: str) -> None:
logger.info("%s", message)
def _on_run_tick(self) -> None:
if self._active_worker is None:
return
if self._last_session_error:
err = self._last_session_error
self._last_session_error = ""
self._active_worker.consume_end_reason()
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error=err))
return
end_reason = self._active_worker.consume_end_reason()
if end_reason in ("ended", "disconnected"):
self._stop_run(reason=end_reason)
return
if end_reason == "error":
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error="acquisition error"))
return
if not self._active_worker.is_running and self._running:
leftover = self._active_worker.consume_end_reason()
if leftover in ("ended", "disconnected"):
self._stop_run(reason=leftover)
elif leftover == "error":
self._stop_run(reason="failed")
self.statusBar().showMessage(
t("status.run_failed", error="acquisition error")
)
else:
self._stop_run(reason="user_stop")
return
packet = self._active_worker.latest()
if packet is None:
return
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if self._session_phase == "connecting" and is_new:
self._session_phase = "running"
self.toolbar.set_reset_enabled(True)
self.statusBar().showMessage(
t("status.run_started", source=self._run_source_label)
)
if is_new:
self._last_plot_packet_key = packet_key
display = select_display_forces(
units_pref=self.toolbar.units(),
force_calibrated=packet.force_calibrated,
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
fn_raw=packet.normal_force_raw,
fx_raw=packet.shear_x_raw,
fy_raw=packet.shear_y_raw,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.push(
packet.timestamp,
display.fn,
display.fx,
display.fy,
unit=display.unit,
)
if self._show_cal:
self.cal_panel.push_raw(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
packet.timestamp,
)
mode = self.arrow_panel.view_mode()
if is_new or mode != self._last_rendered_view_mode:
self._render_run_image(packet)
if self._show_fps and is_new:
self.arrow_panel.set_fps(packet.fps)
def _render_run_image(self, packet) -> None:
if packet is None:
return
from orisys_app_shared.acquisition.arrows import (
depth_map_to_bgr,
draw_arrow_display,
hex_to_bgr,
)
mode = self.arrow_panel.view_mode()
self._last_rendered_view_mode = mode
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
if mode == "depth":
depth_bgr = depth_map_to_bgr(packet.depth)
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
elif mode == "arrows_stitched":
frame = draw_arrow_display(
packet.processed_frame,
packet.flow,
background="captured",
draw_style=draw_style,
valid_mask=packet.valid_mask,
)
else:
canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#121316"))
frame = draw_arrow_display(
packet.processed_frame,
packet.flow,
background="pure_color",
canvas_bgr=canvas_bgr,
draw_style=draw_style,
valid_mask=packet.valid_mask,
)
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _restore_geometry(self) -> None:
geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo:
try:
from PyQt5.QtCore import QByteArray
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
except Exception:
logger.debug("Could not restore window geometry", exc_info=True)
def _persist_prefs(self) -> None:
geo = self.saveGeometry().toHex().data().decode("ascii")
self.prefs["window_geometry"] = geo
self.prefs["theme"] = self._theme
self.prefs["language"] = self._language
self.prefs["camera_id"] = str(self.toolbar.camera_id())
self.prefs["source_mode"] = self.toolbar.source_mode()
self.prefs["video_path"] = self.toolbar.video_path()
self.prefs["units"] = self.toolbar.units()
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg()
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
self.prefs["show_cal_panel"] = bool(self._show_cal)
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
self.prefs["show_fps"] = bool(self._show_fps)
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
self.prefs["config_name"] = self.sensor_picker.config_name()
save_preferences(self.prefs)
def closeEvent(self, event) -> None: # noqa: N802
self._stop_preview(announce=False)
self._stop_run(reason="user_stop")
self._persist_prefs()
logger.info("Customer shell closed; preferences saved")
super().closeEvent(event)
@@ -1,187 +0,0 @@
"""2D image panel: arrows / depth view with mode selector."""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap, QTransform
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
_ROTATION_STEPS = (0, 90, 180, 270)
def _normalize_rotation_deg(value: object, default: int = 90) -> int:
try:
deg = int(value) % 360 # type: ignore[arg-type]
except (TypeError, ValueError):
return default
if deg not in _ROTATION_STEPS:
deg = int(round(deg / 90.0) * 90) % 360
return deg
class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.fps_label = QLabel()
self.fps_label.setObjectName("panelMeta")
self.fps_label.setVisible(False)
self._fps_visible = False
self._fps_value: float | None = None
self._rotation_deg = 90
self._rotation_enabled = True
self._source_image: QImage | None = None
self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
header.addWidget(self.title)
header.addStretch(1)
header.addWidget(self.fps_label)
header.addWidget(self.view_combo)
layout.addLayout(header)
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setMinimumSize(320, 240)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.image_label.setScaledContents(False)
layout.addWidget(self.image_label, 1)
self._placeholder = ""
self._populate_view_combo()
self.retranslate()
def view_mode(self) -> str:
data = self.view_combo.currentData()
return str(data or "arrows_pure")
def set_view_mode(self, mode: str) -> None:
wanted = str(mode or "arrows_pure")
if wanted not in VIEW_MODES:
wanted = "arrows_pure"
idx = self.view_combo.findData(wanted)
if idx < 0:
idx = 0
self.view_combo.blockSignals(True)
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False)
def rotation_deg(self) -> int:
return self._rotation_deg
def set_rotation_deg(self, deg: object) -> None:
self._rotation_deg = _normalize_rotation_deg(deg)
self._repaint_source()
def set_rotation_enabled(self, enabled: bool) -> None:
wanted = bool(enabled)
if wanted == self._rotation_enabled:
return
self._rotation_enabled = wanted
self._repaint_source()
def rotate_90_ccw(self) -> int:
if not self._rotation_enabled:
return self._rotation_deg
self.set_rotation_deg(self._rotation_deg + 90)
return self._rotation_deg
def set_fps_visible(self, visible: bool) -> None:
self._fps_visible = bool(visible)
self._refresh_fps_label()
def set_fps(self, fps: float | None) -> None:
self._fps_value = None if fps is None else float(fps)
self._refresh_fps_label()
def _refresh_fps_label(self) -> None:
if not self._fps_visible or self._fps_value is None:
self.fps_label.clear()
self.fps_label.setVisible(False)
return
self.fps_label.setText(f"{self._fps_value:.1f} FPS")
self.fps_label.setVisible(True)
def _populate_view_combo(self) -> None:
current = self.view_combo.currentData()
self.view_combo.blockSignals(True)
self.view_combo.clear()
for mode in VIEW_MODES:
self.view_combo.addItem("", mode)
if current:
idx = self.view_combo.findData(current)
self.view_combo.setCurrentIndex(max(0, idx))
self.view_combo.blockSignals(False)
def _on_view_index_changed(self, _index: int) -> None:
self.view_mode_changed.emit(self.view_mode())
def retranslate(self) -> None:
self.title.setText(t("panels.arrow"))
labels = (
t("panels.view_arrows_pure"),
t("panels.view_arrows_stitched"),
t("panels.view_depth"),
)
for i, label in enumerate(labels):
if i < self.view_combo.count():
self.view_combo.setItemText(i, label)
self.view_combo.setToolTip(t("panels.view_mode_tip"))
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = t("app.scaffold_hint")
self.image_label.setText(self._placeholder)
def _effective_rotation_deg(self) -> int:
if not self._rotation_enabled:
return 0
return self._rotation_deg
def _repaint_source(self) -> None:
if self._source_image is None or self._source_image.isNull():
return
self._paint_image(self._source_image)
def _paint_image(self, image: QImage) -> None:
pix = QPixmap.fromImage(image)
deg = self._effective_rotation_deg()
if deg:
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
def show_frame(self, image: QImage) -> None:
if image.isNull():
return
self._source_image = image
self._paint_image(image)
def clear_preview(self, message: str | None = None) -> None:
self._source_image = None
self.image_label.clear()
self.image_label.setPixmap(QPixmap())
text = message if message is not None else t("app.scaffold_hint")
self._placeholder = text
self.image_label.setText(text)
self.set_fps(None)
@@ -1,338 +0,0 @@
"""Force calibration sampling panel (FN / FX / FY)."""
from __future__ import annotations
import math
import time
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QRadioButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
)
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
ChannelKey,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
class CalPanel(QFrame):
status_message = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._store = CalSessionStore()
self._channel: ChannelKey = "fn"
self._running = False
self._recording = False
self._record_deadline = 0.0
self._record_started = 0.0
self._record_samples: list[float] = []
self._live_fn = 0.0
self._live_fx = 0.0
self._live_fy = 0.0
self._sensor_type = "box"
self._config_ref: str | None = None
self._record_duration_s = 7.0
root = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
channel_row = QHBoxLayout()
self.channel_group = QButtonGroup(self)
self.radio_fn = QRadioButton("FN")
self.radio_fx = QRadioButton("FX")
self.radio_fy = QRadioButton("FY")
self.radio_fn.setChecked(True)
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
self.channel_group.addButton(radio, i)
channel_row.addWidget(radio)
channel_row.addStretch(1)
self.channel_group.buttonToggled.connect(self._on_channel_button)
root.addLayout(channel_row)
form = QFormLayout()
self._weight_label = QLabel()
self.weight_spin = QDoubleSpinBox()
self.weight_spin.setRange(0.0, 1e6)
self.weight_spin.setDecimals(4)
self.weight_spin.setSingleStep(0.1)
self.weight_spin.setValue(0.0)
self._duration_label = QLabel()
self.duration_spin = QDoubleSpinBox()
self.duration_spin.setRange(5.0, 10.0)
self.duration_spin.setDecimals(1)
self.duration_spin.setSingleStep(0.5)
self.duration_spin.setValue(7.0)
self.duration_spin.setSuffix(" s")
form.addRow(self._weight_label, self.weight_spin)
form.addRow(self._duration_label, self.duration_spin)
root.addLayout(form)
rec_row = QHBoxLayout()
self.btn_record = QPushButton()
self.btn_record.clicked.connect(self._on_record_clicked)
rec_row.addWidget(self.btn_record)
rec_row.addStretch(1)
root.addLayout(rec_row)
self.progress = QProgressBar()
self.progress.setRange(0, 1000)
self.progress.setValue(0)
self.progress.setTextVisible(True)
root.addWidget(self.progress)
self.live_label = QLabel()
self.live_label.setWordWrap(True)
root.addWidget(self.live_label)
self.table = QTableWidget(0, 5)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
root.addWidget(self.table, 1)
sn_row = QHBoxLayout()
self._sn_label = QLabel()
self.sn_edit = QLineEdit()
self.sn_edit.setPlaceholderText("")
sn_row.addWidget(self._sn_label)
sn_row.addWidget(self.sn_edit, 1)
root.addLayout(sn_row)
actions = QHBoxLayout()
self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton()
self.btn_export = QPushButton()
self.btn_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_export.clicked.connect(self._on_export)
actions.addWidget(self.btn_delete)
actions.addWidget(self.btn_clear_channel)
actions.addWidget(self.btn_clear_all)
actions.addStretch(1)
actions.addWidget(self.btn_export)
root.addLayout(actions)
self._refresh_enabled()
self._refresh_table()
self.retranslate()
def set_running(self, running: bool) -> None:
self._running = bool(running)
if not self._running and self._recording:
self._cancel_recording()
self._refresh_enabled()
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
self._sensor_type = str(sensor_type or "box")
self._config_ref = config_ref
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
self._live_fn = float(fn)
self._live_fx = float(fx)
self._live_fy = float(fy)
self._update_live_label()
if not self._recording:
return
now = time.perf_counter()
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
self._record_samples.append(float(value))
span = max(1e-6, self._record_deadline - self._record_started)
frac = min(1.0, max(0.0, (now - self._record_started) / span))
self.progress.setValue(int(frac * 1000))
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
if now >= self._record_deadline:
self._finish_recording()
def retranslate(self) -> None:
self.title.setText(t("panels.cal"))
self._weight_label.setText(t("cal.weight"))
self._duration_label.setText(t("cal.duration"))
self.btn_record.setText(t("cal.record"))
self.btn_delete.setText(t("cal.delete_row"))
self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_export.setText(t("cal.export"))
self._sn_label.setText(t("cal.sn"))
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
headers = (
t("cal.col_weight"),
t("cal.col_raw"),
t("cal.col_n"),
t("cal.col_std"),
t("cal.col_actions"),
)
for i, text in enumerate(headers):
item = self.table.horizontalHeaderItem(i)
if item is None:
item = QTableWidgetItem(text)
self.table.setHorizontalHeaderItem(i, item)
else:
item.setText(text)
self._update_live_label()
self._refresh_enabled()
def _on_channel_button(self, button, checked: bool) -> None:
if not checked:
return
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
key = mapping.get(button)
if key is None:
return
if self._recording:
self._cancel_recording()
self._channel = key # type: ignore[assignment]
self._refresh_table()
def _refresh_enabled(self) -> None:
can_record = self._running and not self._recording
self.btn_record.setEnabled(can_record)
self.weight_spin.setEnabled(not self._recording)
self.duration_spin.setEnabled(not self._recording)
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
radio.setEnabled(not self._recording)
self.btn_export.setEnabled(not self._recording)
def _update_live_label(self) -> None:
self.live_label.setText(
t(
"cal.live_raw",
fn=f"{self._live_fn:.3f}",
fx=f"{self._live_fx:.3f}",
fy=f"{self._live_fy:.3f}",
)
)
def _on_record_clicked(self) -> None:
if not self._running or self._recording:
return
self._recording = True
self._record_samples = []
self._record_duration_s = float(self.duration_spin.value())
self._record_started = time.perf_counter()
self._record_deadline = self._record_started + self._record_duration_s
self.progress.setValue(0)
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
self._refresh_enabled()
self.status_message.emit(t("cal.recording"))
def _cancel_recording(self) -> None:
self._recording = False
self._record_samples = []
self.progress.setValue(0)
self.progress.setFormat("")
self._refresh_enabled()
def _finish_recording(self) -> None:
samples = list(self._record_samples)
self._recording = False
self._record_samples = []
self.progress.setValue(1000)
self._refresh_enabled()
if len(samples) < 2:
self.status_message.emit(t("cal.record_failed_few"))
return
mean = sum(samples) / len(samples)
var = sum((x - mean) ** 2 for x in samples) / len(samples)
std = math.sqrt(var)
sample = CalSample(
weight_n=float(self.weight_spin.value()),
raw_mean=mean,
n=len(samples),
std=std,
)
self._store.upsert(self._channel, sample)
self._refresh_table()
self.status_message.emit(
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
)
def _refresh_table(self) -> None:
rows = self._store.channel(self._channel)
self.table.setRowCount(len(rows))
for r, sample in enumerate(rows):
values = (
f"{sample.weight_n:g}",
f"{sample.raw_mean:.4f}",
str(sample.n),
f"{sample.std:.4f}",
"",
)
for c, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(r, c, item)
def _on_delete_row(self) -> None:
row = self.table.currentRow()
if row < 0:
return
self._store.remove_at(self._channel, row)
self._refresh_table()
def _on_clear_channel(self) -> None:
self._store.clear(self._channel)
self._refresh_table()
def _on_clear_all(self) -> None:
self._store.clear()
self._refresh_table()
def _on_export(self) -> None:
meta = meta_from_sensor_json(self._config_ref)
meta["sensor_type"] = self._sensor_type
meta["sn"] = self.sn_edit.text().strip()
meta["record_duration_s"] = float(self.duration_spin.value())
local_bundle = sensors_dir() / self._sensor_type
if not local_bundle.is_dir():
local_bundle = None
try:
primary, local_path, warnings = export_force_calibration_v2(
self._store,
export_dir=calibration_export_dir(),
sensor_type=self._sensor_type,
meta=meta,
local_bundle_dir=local_bundle,
)
except ValueError as exc:
QMessageBox.warning(self, t("panels.cal"), str(exc))
return
except Exception as exc:
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
return
msg = t("cal.export_ok", path=str(primary))
if local_path is not None:
msg += "\n" + t("cal.export_local", path=str(local_path))
if warnings:
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
self.status_message.emit(msg)
QMessageBox.information(self, t("panels.cal"), msg)
@@ -1,190 +0,0 @@
"""FN / FX / FY force charts with live value labels."""
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for force charts") from exc
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
class ForcePlotsPanel(QFrame):
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
self._fn_max = 20.0
self._shear_max = 20.0
self._fixed_newton = True
self._unit = "raw"
self.history = MetricHistory(
channels=3,
window_sec=4.0,
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
y_axis_min_span=0.5,
)
root = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plots = []
self.curves = []
names = ("FN", "FX", "FY")
for i, name in enumerate(names):
plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setLabel("left", name)
plot.setMouseEnabled(x=False, y=False)
if i > 0:
plot.setXLink(self.plots[0])
curve = plot.plot(pen=pg.mkPen("#8ab4f8", width=2))
self.plots.append(plot)
self.curves.append(curve)
root.addWidget(self.plot_widget, 1)
values_row = QHBoxLayout()
self.value_labels = []
for _ in names:
lab = QLabel("—")
lab.setAlignment(Qt.AlignCenter)
values_row.addWidget(lab)
self.value_labels.append(lab)
root.addLayout(values_row)
self.set_theme(theme)
self._apply_axis_mode()
self.retranslate()
def set_theme(self, theme: str) -> None:
self._theme = theme
bg = color(theme, "plot_bg", "#121316")
text = color(theme, "text_primary", "#e8eaed")
self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text)
for plot in self.plots:
try:
plot.getViewBox().setBackgroundColor(QColor(bg))
except Exception:
pass
for axis_name in ("left", "bottom"):
axis = plot.getAxis(axis_name)
axis.setPen(axis_pen)
axis.setTextPen(axis_pen)
plot.showGrid(x=True, y=True, alpha=0.25)
label = plot.getAxis("left").labelText or ""
if label:
plot.setLabel("left", label, color=text)
pens = (
color(theme, "plot_fn", "#8ab4f8"),
color(theme, "plot_fx", "#34a853"),
color(theme, "plot_fy", "#fbbc04"),
)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
def set_window_sec(self, sec: float) -> None:
self.history.set_window_sec(sec)
self._clear_curves()
self._apply_axis_mode()
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
self._apply_axis_mode()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
self.history.reset()
self._clear_curves()
for lab in self.value_labels:
lab.setText("—")
self._apply_axis_mode()
def push(
self,
timestamp: float,
fn: float,
fx: float,
fy: float,
*,
unit: str = "raw",
) -> None:
self._unit = unit
use_fixed = self._uses_fixed_y()
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
self.history.push(timestamp, plot_fn, float(fx), float(fy))
self._update_value_labels(fn, fx, fy)
state = self.history.state()
if state is None:
return
values = state.values.copy()
if use_fixed:
values[:, 0] = np.maximum(values[:, 0], 0.0)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
self.plots[i].setYRange(ymin, ymax, padding=0.0)
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
def _uses_fixed_y(self) -> bool:
return bool(self._fixed_newton) and self._unit == "N"
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
if channel_index == 0:
return 0.0, self._fn_max
return -self._shear_max, self._shear_max
def _apply_axis_mode(self) -> None:
# Preview fixed newton axes when newton mode is selected, even before samples.
preview_fixed = bool(self._fixed_newton)
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
plot.setYRange(ymin, ymax, padding=0.0)
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
plot.getAxis("left").setTicks(None)
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
def _clear_curves(self) -> None:
for curve in self.curves:
curve.setData([], [])
def _update_value_labels(self, fn: float, fx: float, fy: float) -> None:
unit = self._unit
fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}"
for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)):
lab.setText(fmt.format(name=name, value=value, unit=unit))
def retranslate(self) -> None:
self.title.setText(t("panels.plots"))
@@ -1,114 +0,0 @@
"""Modal dialog for force-plot window and Y-axis limits."""
from __future__ import annotations
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
class PlotSettingsDialog(QDialog):
def __init__(
self,
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
units: str = "newton",
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(360, 200)
root = QVBoxLayout(self)
form = QFormLayout()
self.window_label = QLabel()
self.window_spin = QDoubleSpinBox()
self.window_spin.setRange(1.0, 30.0)
self.window_spin.setSingleStep(1.0)
self.window_spin.setDecimals(0)
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
self.fn_max_spin.setSingleStep(1.0)
self.fn_max_spin.setDecimals(0)
self.fn_max_spin.setSuffix(" N")
self.fn_max_spin.setValue(float(fn_max))
self.shear_max_label = QLabel()
self.shear_max_spin = QDoubleSpinBox()
self.shear_max_spin.setRange(1.0, 100.0)
self.shear_max_spin.setSingleStep(1.0)
self.shear_max_spin.setDecimals(0)
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
self.fn_max_spin.setEnabled(newton)
self.shear_max_spin.setEnabled(newton)
self.fn_max_label.setEnabled(newton)
self.shear_max_label.setEnabled(newton)
buttons_row = QHBoxLayout()
self.btn_reset = QPushButton()
self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box)
root.addLayout(buttons_row)
self.retranslate()
def values(self) -> tuple[float, float, float]:
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
float(self.shear_max_spin.value()),
)
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
def retranslate(self) -> None:
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
if ok_btn is not None:
ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None:
cancel_btn.setText(t("dialog.cancel"))
@@ -1,261 +0,0 @@
"""Top toolbar: source, camera/video, run/reset, units."""
from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
QStackedWidget,
QWidget,
)
from orisys_app_shared.i18n import t
class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
source_mode_changed = pyqtSignal(str)
browse_video_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.source_label = QLabel()
self.source_combo = QComboBox()
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
self.source_stack = QStackedWidget()
cam_page = QWidget()
cam_layout = QHBoxLayout(cam_page)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(8)
self.cam_id_label = QLabel()
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setMinimumWidth(72)
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
video_page = QWidget()
video_layout = QHBoxLayout(video_page)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(8)
self.video_edit = QLineEdit()
self.video_edit.setMinimumWidth(180)
self.video_edit.setReadOnly(True)
self.btn_browse_video = QPushButton()
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
video_layout.addWidget(self.video_edit, 1)
video_layout.addWidget(self.btn_browse_video)
self.source_stack.addWidget(cam_page)
self.source_stack.addWidget(video_page)
self.btn_search = QPushButton()
self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton()
self.btn_preview.setCheckable(True)
self.btn_preview.toggled.connect(self._on_preview_toggled)
self.btn_run = QPushButton()
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
self.btn_reset = QPushButton()
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit)
self.units_label = QLabel()
self.units_combo = QComboBox()
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
layout.addWidget(self.source_label)
layout.addWidget(self.source_combo)
layout.addWidget(self.source_stack, 1)
layout.addWidget(self.btn_search)
layout.addWidget(self.btn_preview)
layout.addWidget(self.btn_run)
layout.addWidget(self.btn_reset)
layout.addWidget(self.units_label)
layout.addWidget(self.units_combo)
layout.addStretch(1)
self._populate_source_combo()
self._populate_units_combo()
self.retranslate()
self._apply_source_mode_ui()
def source_mode(self) -> str:
data = self.source_combo.currentData()
return str(data or "camera")
def set_source_mode(self, mode: str) -> None:
wanted = "video" if str(mode).lower().startswith("v") else "camera"
idx = self.source_combo.findData(wanted)
if idx < 0:
idx = 0
self.source_combo.blockSignals(True)
self.source_combo.setCurrentIndex(idx)
self.source_combo.blockSignals(False)
self._apply_source_mode_ui()
def camera_id(self) -> int:
return int(self.cam_id_spin.value())
def set_camera_id(self, value: int) -> None:
self.cam_id_spin.blockSignals(True)
self.cam_id_spin.setValue(int(value))
self.cam_id_spin.blockSignals(False)
def video_path(self) -> str:
return self.video_edit.text().strip()
def set_video_path(self, path: str) -> None:
text = str(path or "").strip()
self.video_edit.setText(text)
if text:
self.video_edit.setToolTip(text)
else:
self.video_edit.setToolTip("")
def units(self) -> str:
data = self.units_combo.currentData()
return str(data or "newton")
def set_units(self, units: str) -> None:
wanted = "raw" if str(units).lower().startswith("r") else "newton"
idx = self.units_combo.findData(wanted)
if idx < 0:
idx = 0
self.units_combo.blockSignals(True)
self.units_combo.setCurrentIndex(idx)
self.units_combo.blockSignals(False)
def set_preview_checked(self, checked: bool) -> None:
self.btn_preview.blockSignals(True)
self.btn_preview.setChecked(bool(checked))
self.btn_preview.blockSignals(False)
self._refresh_preview_label()
def set_run_checked(self, checked: bool) -> None:
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
def set_camera_controls_enabled(self, enabled: bool) -> None:
self.btn_search.setEnabled(enabled and self.source_mode() == "camera")
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera")
def set_source_controls_enabled(self, enabled: bool) -> None:
self.source_combo.setEnabled(enabled)
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video")
self.set_camera_controls_enabled(enabled)
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
def set_run_enabled(self, enabled: bool) -> None:
self.btn_run.setEnabled(bool(enabled))
def active_source(self) -> str | int | None:
if self.source_mode() == "video":
path = self.video_path()
return path if path else None
return self.camera_id()
def _populate_source_combo(self) -> None:
self.source_combo.blockSignals(True)
self.source_combo.clear()
self.source_combo.addItem("", "camera")
self.source_combo.addItem("", "video")
self.source_combo.blockSignals(False)
def _populate_units_combo(self) -> None:
current = self.units_combo.currentData()
self.units_combo.blockSignals(True)
self.units_combo.clear()
self.units_combo.addItem("", "newton")
self.units_combo.addItem("", "raw")
if current:
idx = self.units_combo.findData(current)
self.units_combo.setCurrentIndex(max(0, idx))
self.units_combo.blockSignals(False)
def _apply_source_mode_ui(self) -> None:
video = self.source_mode() == "video"
self.source_stack.setCurrentIndex(1 if video else 0)
self.btn_search.setEnabled(not video)
self.btn_preview.setEnabled(not video)
if video and self.btn_preview.isChecked():
self.set_preview_checked(False)
def _on_source_index_changed(self, _index: int) -> None:
self._apply_source_mode_ui()
self.source_mode_changed.emit(self.source_mode())
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
self.units_changed.emit(self.units())
def _refresh_preview_label(self) -> None:
if self.btn_preview.isChecked():
self.btn_preview.setText(t("toolbar.preview_stop"))
else:
self.btn_preview.setText(t("toolbar.preview"))
def _refresh_run_label(self) -> None:
if self.btn_run.isChecked():
self.btn_run.setText("■")
self.btn_run.setToolTip(t("toolbar.stop"))
else:
self.btn_run.setText("▶")
self.btn_run.setToolTip(t("toolbar.run"))
def retranslate(self) -> None:
self.source_label.setText(t("toolbar.source"))
self.source_combo.setItemText(0, t("toolbar.source_camera"))
self.source_combo.setItemText(1, t("toolbar.source_video"))
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_browse_video.setText(t("toolbar.browse_video"))
path = self.video_path()
self.video_edit.setPlaceholderText(
Path(path).name if path else t("toolbar.video_placeholder")
)
self.btn_search.setText(t("toolbar.search_cameras"))
self._refresh_preview_label()
self._refresh_run_label()
self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))
+371
View File
@@ -0,0 +1,371 @@
{
"app": {
"title": "Orisys Studio",
"about_title": "About",
"about_body": "Orisys Studio desktop application\nVersion: {app_version}",
"scaffold_hint": "Scaffold shell — acquisition coming later"
},
"brand": {
"name": "ORISYS",
"tagline": "DieDong Technology",
"nav_live": "Live",
"nav_live_tip": "Current view: live monitoring. Click to leave history browsing.",
"nav_playback": "Replay",
"nav_playback_tip": "Open a recording: video, curves and one shared cursor",
"nav_calibration": "Calibration",
"nav_calibration_tip": "Show the calibration panel"
},
"menu": {
"file": "File",
"open_video": "Open video…",
"reveal_config": "Reveal sensor config…",
"playback": "Open recording replay…",
"playback_latest": "Replay most recent recording",
"view": "View",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"language": "Language",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "Calibration panel",
"finger_3d": "Finger 3D view",
"show_fps": "Show FPS",
"settings": "Settings area",
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
"recompute": "Recompute forces from the video",
"recompute_tip": "Off: the charts read the recorded forces.csv. On: re-run the vision pipeline over the recorded video (an algorithm check, forward-only).",
"run": "Run / Stop",
"stop": "Cancel acquisition",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"help": "Help",
"about": "About"
},
"dialog": {
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"plot_fn_raw_max": "FN raw max",
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
"plot_shear_raw_max": "Shear raw max",
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
"reset_defaults": "Reset to defaults",
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
"filter_preset": "Preset",
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
"filter_preset_custom": "Custom",
"filter_flow_ema": "Flow EMA α",
"filter_flow_ema_tip": "Temporal smoothing of the optical flow. 1.00 = off. It changes what is *measured* (contact statistics and forces along with it), so two recordings with different α are not comparable — only use it when the flow jitter really has to go",
"filter_median": "Median window",
"filter_median_tip": "Sliding-median window over the force readings (samples). 1 = off. Removes single-sample spikes at the cost of about half a window of delay",
"filter_lowpass": "Low-pass cutoff",
"filter_lowpass_tip": "Low-pass cutoff for the force readings (Hz). 0 = off. Must stay below Nyquist (half the frame rate) or the device will not filter at all",
"filter_order": "Low-pass order",
"filter_order_tip": "Low-pass filter order (1–6). Steeper, with more phase distortion",
"filter_rate": "Sample rate",
"filter_rate_tip": "The rate the low-pass coefficients are designed for (Hz)",
"filter_rate_auto": "Follow the measured frame rate",
"filter_rate_auto_tip": "Design the low-pass for the device's measured rate; clear it to pin a number",
"filter_measurement_note": "The flow EMA is a *measurement* setting: changing it makes this recording incomparable with earlier ones.",
"filter_locked": "Filters cannot change while recording; this applies to the next session.",
"filter_nyquist": "Note: a {cutoff} Hz cutoff is at or above the Nyquist frequency of {nyquist} Hz (sample rate {rate} Hz) — the device would not filter. Lower the cutoff or pin a higher sample rate.",
"filter_off": "Not filtering (raw readings)",
"ok": "OK",
"cancel": "Cancel"
},
"toolbar": {
"search_cameras": "Search",
"cam_id": "Camera",
"use_camera": "Use camera",
"use_camera_tip": "Go back to acquiring from a camera (the data source is a video file)",
"choose_video": "Choose a recorded video…",
"choose_video_tip": "Open a recorded video: the 2D panel plays it and the charts read the forces.csv beside it",
"preview": "Preview",
"preview_stop": "Stop preview",
"run": "Run",
"stop": "Stop",
"run_label": "▶ Run",
"stop_label": "■ Stop",
"cancel_label": "■ Cancel",
"cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)",
"run_unavailable": "Not available yet",
"run_disabled_video": "Select a video file first",
"run_disabled_sensor": "Run is not available for this sensor type",
"playback": "▶ Play",
"playback_pause": "⏸ Pause",
"playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses",
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
"reset": "Reset",
"units": "Units",
"units_newton": "Newton",
"units_raw": "Raw",
"settings": "Settings",
"settings_tip": "Show or hide the unit, record-mode and preview controls"
},
"units": {
"newton_short": "N",
"raw_short": "raw"
},
"sensor": {
"type": "Sensor",
"config": "Sensor config",
"type_tip": "Pick the product/sensor: box, mini or finger. It decides which config files and algorithm parameters exist.",
"config_tip": "The algorithm config file (config/*.json) this sensor uses. Switching the sensor switches the available configs with it.",
"summary": "{type} · {config}",
"dialog_title": "Sensor and calibration",
"dialog_tip": "Pick the sensor type and config, and read the calibration in effect and the last exported coefficients",
"calib_section": "Calibration in effect",
"calib_version": "Version confirmed by the device",
"calib_axes": "Calibrated axes",
"calib_config": "Config",
"calib_none": "the device has not confirmed a calibration",
"export_section": "Last exported coefficients",
"export_none": "No coefficients exported yet",
"export_file": "file {name} ({size} KB, {time})",
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
"thumb_placeholder": "Product model"
},
"panels": {
"arrow": "2D image",
"view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map",
"view_recorded": "Recorded image",
"view_mode_tip": "Image display mode while running",
"replay_pipeline": "Recorded video (raw camera image)",
"replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to check the algorithm, use View → Recompute forces from the video",
"replay_empty": "No recording open\nPress “Open recording…” below",
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
"depth_scale": "Depth scale {low} – {high}",
"arrow_scale": "Arrows: displacement ×{factor}",
"arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).",
"arrow_pipeline": "Captured image → algorithm output",
"arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow",
"arrow_area_masked": "Valid area",
"arrow_area_full": "Full frame",
"depth_scale_tip": "Fixed for this session: the range is latched to the first frame that carried depth so frames stay comparable. Grey marks samples with no depth.",
"plots": "Force Charts",
"cal": "Calibration",
"mesh3d": "3D mesh",
"mesh3d_empty": "Finger 3D view placeholder —\nOpen3D mesh coming later",
"mesh3d_empty_tip": "3D mesh preview for finger sensors"
},
"chart": {
"fn": "FN",
"fx": "FX",
"fy": "FY",
"fn_name": "Normal force",
"fx_name": "Shear X",
"fy_name": "Shear Y",
"window_readout": "{start} – {end} s / {total} s total",
"window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session",
"no_data": "—",
"mode_follow": "Following live",
"mode_follow_tip": "The charts follow the newest sample and the window scrolls with it",
"mode_browse": "Browsing history",
"mode_browse_tip": "Display paused, capture and recording continue; drag or scroll to move the window, “back to latest” returns to live",
"mode_replay": "Replaying a recording (browsing)",
"mode_replay_tip": "The charts read the open recording; capture and recording continue",
"return_latest": "Back to latest",
"return_latest_tip": "Leave history browsing and follow the newest data again",
"overview": "Whole session",
"overview_tip": "Fit the entire session; drag the overview strip to move the window",
"box_zoom": "Box zoom",
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
"settings": "Chart settings",
"settings_tip": "Time window, axis ranges, and the acquisition filters (screen and record use the same data)",
"selection": "Selection {duration}s / {samples} samples — {detail}",
"selection_summary": "Selection {duration}s / {samples} samples",
"selection_detail": "mean {mean} · σ {std}",
"selection_peak_tip": "The large number is the selection's peak; the line under it is its mean",
"selection_empty": "No valid samples in the selection",
"value_unavailable": "n/a",
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
"unit_toggle": "Switch unit",
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
"browse_only_tip": "Drag or scroll on the chart to browse history"
},
"playback": {
"title": "Replay recording",
"open": "Open a recorded video…",
"video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)",
"no_video": "This recording has no usable video segment",
"open_failed": "Could not open that recording: {error}",
"none_found": "No recording found under outputs/recordings",
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
"frame_unreadable": "This frame could not be decoded from the video",
"warnings": "Replay notes: {detail}",
"decimated": "This recording is long, so the curves are a decimated view ({shown} of {total} rows; intervals are read from disk on demand)",
"warn_config_changed": "the config differs from recording time, so recomputing from the video may differ",
"warn_sdk_changed": "the SDK version differs from recording time, so recomputing may differ",
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
"warn_rows_skipped": "some unparsable CSV rows were skipped",
"warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")",
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ"
},
"record": {
"label": "Record",
"mode_video_data": "Video + data",
"mode_data_only": "Data only",
"mode_tip": "Video records the pre-processing camera input (no arrows or text overlays). MP4 is a lossy container, not a lossless copy of the raw data.",
"start_label": "● Start recording",
"stop_label": "■ Stop recording",
"start_tip": "Record this experiment: video, force data and metadata into one directory",
"stop_tip": "Stop recording and write session.json (always available)",
"requires_run": "Press Run to start acquisition before recording",
"started": "Recording started ({mode})",
"start_failed": "Could not start recording: {error}",
"finished": "Recording finished — {frames} frames / {segments} segment(s) in {path}",
"finished_with_problems": "Recording finished with problems: {detail}",
"closing": "Closing the recording files…",
"failed_state": "The recording failed (see the summary and the log)",
"alarm": "Recording problem: {detail}",
"state_idle": "No recording",
"state_recording": "Recording {duration}",
"state_closing": "Closing files…",
"state_error": "Write error ({count})",
"state_done": "Last recording: {frames} frames",
"dropped": "{count} dropped",
"strip_idle": "This session has not started a recording yet",
"strip_no_error": "none",
"strip_no_path": "not created yet",
"strip_tip": "Recording state: {state}\nFrames {frames} / segments {segments} / duration {duration}\nDropped {dropped} (data {dropped_data}, video {dropped_video}, gaps {dropped_gaps})\nWrite errors {errors}\nLast error: {detail}\nOutput directory {path}"
},
"cal": {
"weight": "Weight (N)",
"duration": "Record duration",
"record": "Record",
"recording_stop": "Stop",
"record_disabled": "Press Run to start acquisition before recording samples",
"record_cancelled": "Recording cancelled",
"empty_hint": "No samples for this channel yet: run, pick a channel and press Record.",
"delete_row": "Delete row",
"clear_channel": "Clear channel",
"clear_all": "Clear all",
"export": "Export coefficients…",
"export_tip": "Write the SDK coefficient file only; the running device is untouched",
"apply": "Apply to device",
"apply_tip": "Push the last export to the running device (confirmed again)",
"apply_no_export": "No exported coefficients yet — export them first",
"save_record": "Save record…",
"save_record_tip": "Save every point of this session with its full three-axis samples (audit data), independently of the coefficient file",
"save_record_empty": "No calibration points to save yet",
"save_record_ok": "Saved the calibration record to {path}\n{points} point(s), {samples} three-axis samples",
"save_record_failed": "Could not save the calibration record: {error}",
"open_record": "Open record…",
"open_record_tip": "Reopen a saved calibration session to inspect every point's samples and diagnostics",
"open_record_filter": "Calibration records (*.json)",
"open_record_ok": "Loaded {path} ({points} point(s))",
"open_record_device": "Recorded on: {device}",
"open_record_warnings": "Note: {detail}",
"open_record_failed": "Could not open the calibration record: {error}",
"apply_mismatch_title": "Calibration does not match the target device",
"apply_mismatch": "This coefficient file belongs to: {device}\nMismatch: {detail}\n\nCurrent target: {sensor_type} / {config}\n\nApplying it would load coefficients that were not measured on this device.",
"apply_mismatch_skipped": "Apply cancelled (the calibration belongs to another device)",
"sn": "SN",
"sn_placeholder": "Serial number (optional)",
"live_raw_label": "Live raw",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
"col_weight": "Reference load",
"col_fn": "FN mean",
"col_fx": "FX mean",
"col_fy": "FY mean",
"col_stability": "Stability",
"col_state": "State",
"col_raw": "Raw mean",
"col_n": "n",
"col_std": "σ",
"state_ok": "OK",
"state_excluded": "Excluded",
"state_validation": "Validation",
"state_exclude": "Exclude",
"state_exclude_tip": "Drop the selected point from the fit and the export (kept on record, reversible)",
"state_restore": "Restore",
"mark_validation": "Mark validation",
"mark_validation_tip": "Hold the selected point out of the fit and use it to test the fit",
"excluded": "Point excluded from the fit",
"restored": "Point restored",
"marked_validation": "Point marked as a validation point",
"unmarked_validation": "Validation mark removed",
"fit_insufficient": "A fit needs at least 2 usable points (currently {count})",
"fit_summary": "Exported model {channel}: {method} ({count} points / {breakpoints} breakpoints, max residual {residual} N)",
"fit_validation": "Independent validation on {count} point(s), max deviation {error} N",
"method_piecewise_linear": "piecewise linear",
"method_linear": "linear",
"point_title": "Calibration point {channel} @ {load} N",
"point_channel": "Loaded axis",
"point_direction": "Direction",
"point_round": "Round",
"point_duration": "Actual sampling time",
"point_duration_value": "{actual} s (requested {requested} s)",
"point_samples": "Samples",
"point_samples_value": "{valid} valid / {total} total",
"point_axes": "Three-axis mean ± std",
"point_residual": "Fit residual",
"col_actions": "",
"recording": "Recording calibration samples…",
"recording_stop_tip": "Click to cancel this calibration recording (always available)",
"record_ok": "Recorded weight={weight} raw={raw}",
"record_failed_few": "Recording failed — too few samples",
"export_ok": "Exported {path}",
"export_local": "Also wrote {path}",
"export_warnings": "Skipped: {detail}",
"export_failed": "Export failed: {error}",
"export_no_model": "Nothing to export: {detail}",
"apply_title": "Apply calibration",
"apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.",
"apply_skipped": "Exported; not applied to the device",
"apply_pending": "Requested calibration for {sensor_type}; waiting for the device to confirm…",
"applied": "Device confirmed the calibration is in effect (version {version})",
"applied_none": "Device confirmed: no calibration is loaded (the column stays empty)",
"export_only": "Exported coefficients to {path} (not applied to the device)",
"apply_no_target": "No device to apply to (run an acquisition first)"
},
"status": {
"phase_idle": "Idle",
"phase_connecting": "Connecting",
"phase_running": "Acquiring",
"phase_error": "Error",
"rail_video_name": "Video file: {name}",
"data_valid": "Data valid",
"data_invalid": "{count} invalid sample(s)",
"calibrated": "Calibrated",
"calibrated_all": "Calibrated · 3 axes",
"calibrated_axes": "Calibrated · {axes}",
"uncalibrated": "Uncalibrated",
"config_none": "no config",
"camera_none": "No cameras found",
"camera_found": "Found {count} camera(s): {names}",
"preview_started": "Preview started (camera {camera_id})",
"preview_stopped": "Preview stopped",
"preview_failed": "Preview failed: {error}",
"connect_slow": "This is taking a while — click “Cancel” or press Esc to abort",
"run_stopped": "Stopped",
"run_stopping": "Stopping… (releasing the device)",
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
"sample_invalid": "Invalid sample (#{count}): {reason} — marked invalid and excluded from the curves and from calibration",
"samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)",
"run_failed": "Run failed: {error}",
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
"rail_camera_fps": "camera {fps}",
"capture_slow": "The camera delivers less than the configured frame rate: {detail}. The recording keeps the real rate. This is a capture-side (camera/driver) matter that software cannot change: use a camera that really does deliver 30 fps — after checking that the cable or port is not slowing this one down — and measure again.",
"video_ended": "Video ended",
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
"units_raw_fallback": "Calibration unavailable — showing raw units",
"reveal_failed": "Could not reveal config: {error}"
}
}
+371
View File
@@ -0,0 +1,371 @@
{
"app": {
"title": "Orisys Studio",
"about_title": "关于",
"about_body": "Orisys Studio 桌面应用\n版本: {app_version}",
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
},
"brand": {
"name": "ORISYS",
"tagline": "叠动科技",
"nav_live": "实时监测",
"nav_live_tip": "当前视图:实时监测。点击可退出历史浏览。",
"nav_playback": "历史回放",
"nav_playback_tip": "打开一段录制:视频、曲线与同一游标",
"nav_calibration": "标定",
"nav_calibration_tip": "显示标定面板"
},
"menu": {
"file": "文件",
"open_video": "打开视频…",
"reveal_config": "在资源管理器中显示配置…",
"playback": "打开录制回放…",
"playback_latest": "回放最近一次录制",
"view": "视图",
"theme": "主题",
"theme_dark": "深色",
"theme_light": "浅色",
"language": "语言",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "标定面板",
"finger_3d": "手指 3D 视图",
"show_fps": "显示 FPS",
"settings": "设置区",
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
"recompute": "由视频重算力值",
"recompute_tip": "关闭:曲线读录制的 forces.csv(当时测得的值)。打开:把录制的视频重新跑一遍视觉管线(算法复核,只能顺序前进)。",
"run": "运行 / 停止",
"stop": "取消采集",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"help": "帮助",
"about": "关于"
},
"dialog": {
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"plot_fn_raw_max": "FN 原始上限",
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
"plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"reset_defaults": "恢复默认",
"plot_filters": "滤波(作用于采集,界面与记录同步)",
"filter_preset": "预设",
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
"filter_preset_custom": "自定义",
"filter_flow_ema": "光流 EMA α",
"filter_flow_ema_tip": "光流的时间平滑。1.00 = 关。它改变的是“测量本身”(接触统计与力值都会变),两个 α 不同的录制不可比 —— 只有确实需要压光流抖动时才调",
"filter_median": "中值窗口",
"filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟",
"filter_lowpass": "低通截止",
"filter_lowpass_tip": "力读数低通截止频率(Hz)。0 = 关。必须在奈奎斯特频率(帧率的一半)以下,否则设备不会滤",
"filter_order": "低通阶数",
"filter_order_tip": "低通滤波器阶数(1–6)。越高越陡,相位失真也越大",
"filter_rate": "采样率",
"filter_rate_tip": "低通系数按哪个采样率设计(Hz)",
"filter_rate_auto": "自动跟随帧率",
"filter_rate_auto_tip": "按设备实测帧率设计低通系数;取消可固定一个数值",
"filter_measurement_note": "光流 EMA 属于“测量”设置:改了它,本次录制与之前的不可比。",
"filter_locked": "录制中不能更改滤波;下一次采集生效。",
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
"filter_off": "当前不滤波(原始读数)",
"ok": "确定",
"cancel": "取消"
},
"toolbar": {
"search_cameras": "搜索",
"cam_id": "摄像机",
"use_camera": "使用摄像机",
"use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)",
"choose_video": "选择录制视频…",
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
"preview": "预览",
"preview_stop": "停止预览",
"run": "运行",
"stop": "停止",
"run_label": "▶ 运行",
"stop_label": "■ 停止",
"cancel_label": "■ 取消连接",
"cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)",
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"playback": "▶ 回放",
"playback_pause": "⏸ 暂停",
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
"reset": "重置",
"units": "单位",
"units_newton": "牛顿",
"units_raw": "原始",
"settings": "设置",
"settings_tip": "展开或收起单位、记录模式与预览"
},
"units": {
"newton_short": "N",
"raw_short": "raw"
},
"sensor": {
"type": "传感器",
"config": "传感器配置",
"type_tip": "选择产品/传感器:box、mini、finger。它决定可用的配置文件与算法参数。",
"config_tip": "该传感器使用的算法配置文件(config/*.json)。切换传感器会同时切换可选配置。",
"summary": "{type} · {config}",
"dialog_title": "传感器与标定",
"dialog_tip": "选择传感器类型与配置文件,并查看当前生效的标定与最近导出的系数",
"calib_section": "当前生效的标定",
"calib_version": "设备确认的版本",
"calib_axes": "已标定轴",
"calib_config": "配置",
"calib_none": "设备尚未确认加载标定",
"export_section": "最近导出的系数",
"export_none": "还没有导出过系数",
"export_file": "文件 {name}({size} KB,{time})",
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
"thumb_placeholder": "产品型号"
},
"panels": {
"arrow": "2D 图像",
"view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图",
"view_recorded": "原始图像",
"view_mode_tip": "运行时的图像显示模式",
"replay_pipeline": "录制视频(原始相机图像)",
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要复核算法请用「视图 → 由视频重算力值」",
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
"depth_scale": "深度色标 {low} – {high}",
"arrow_scale": "箭头按位移放大 ×{factor}",
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
"arrow_pipeline": "原始图像 → 算法输出",
"arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头",
"arrow_area_masked": "有效区域",
"arrow_area_full": "全画幅",
"depth_scale_tip": "本会话固定色标:色标范围锁定在首个有效深度帧,便于跨帧比较;灰色区域表示无深度数据",
"plots": "力曲线图",
"cal": "标定",
"mesh3d": "三维网格",
"mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入",
"mesh3d_empty_tip": "手指传感器的三维网格预览"
},
"chart": {
"fn": "FN",
"fx": "FX",
"fy": "FY",
"fn_name": "法向力",
"fx_name": "切向力 X",
"fy_name": "切向力 Y",
"window_readout": "{start} – {end} s / 全程 {total} s",
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
"no_data": "—",
"mode_follow": "跟随实时",
"mode_follow_tip": "曲线跟随最新样本,窗口随采集滚动",
"mode_browse": "浏览历史",
"mode_browse_tip": "显示暂停,采集与记录继续;拖动或滚轮改变窗口,点「返回最新」回到实时",
"mode_replay": "回放录制(浏览)",
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
"return_latest": "返回最新",
"return_latest_tip": "退出历史浏览,恢复跟随最新数据",
"overview": "全程总览",
"overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗",
"box_zoom": "框选",
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
"settings": "曲线图设置",
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
"selection": "选区 {duration}s / {samples} 点 — {detail}",
"selection_summary": "选区 {duration}s / {samples} 点",
"selection_detail": "均 {mean} · σ {std}",
"selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值",
"selection_empty": "选区无有效样本",
"value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
"unit_toggle": "切换单位",
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
},
"playback": {
"title": "录制回放",
"open": "打开录制视频…",
"video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
"no_video": "这段录制没有可用的视频分段",
"open_failed": "无法打开该录制: {error}",
"none_found": "outputs/recordings 下没有找到录制",
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
"frame_unreadable": "该帧无法从视频中解码",
"warnings": "回放提示: {detail}",
"decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)",
"warn_config_changed": "配置文件与录制时不一致:由视频重算的结果可能与当时不同",
"warn_sdk_changed": "SDK 版本与录制时不一致:由视频重算的结果可能不同",
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同"
},
"record": {
"label": "记录",
"mode_video_data": "视频 + 数据",
"mode_data_only": "仅数据",
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
"start_label": "● 开始记录",
"stop_label": "■ 停止记录",
"start_tip": "开始记录本实验:视频、力数据与元数据写入同一目录",
"stop_tip": "停止记录并写入 session.json(随时可用)",
"requires_run": "请先点击「运行」开始采集,再开始记录",
"started": "已开始记录({mode})",
"start_failed": "无法开始记录: {error}",
"finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}",
"finished_with_problems": "记录完成,但存在问题: {detail}",
"closing": "正在关闭记录文件…",
"failed_state": "记录已失败(详见摘要与日志)",
"alarm": "记录异常: {detail}",
"state_idle": "尚未记录",
"state_recording": "记录中 {duration}",
"state_closing": "正在写入…",
"state_error": "写入异常({count})",
"state_done": "上次记录 {frames} 帧",
"dropped": "丢帧 {count}",
"strip_idle": "本次会话还没有开始记录",
"strip_no_error": "无",
"strip_no_path": "尚未创建",
"strip_tip": "记录状态: {state}\n帧 {frames} / 分段 {segments} / 时长 {duration}\n丢帧 {dropped}(数据 {dropped_data}、视频 {dropped_video}、空洞 {dropped_gaps})\n写入错误 {errors}\n最近错误: {detail}\n输出目录 {path}"
},
"cal": {
"weight": "砝码重量 (N)",
"duration": "采集时长",
"record": "记录",
"recording_stop": "停止",
"record_disabled": "请先点击「运行」开始采集,再记录标定样本",
"record_cancelled": "已取消本次采集",
"empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。",
"delete_row": "删除行",
"clear_channel": "清空通道",
"clear_all": "全部清空",
"export": "导出系数…",
"export_tip": "只写出 SDK 标定系数文件,不改变运行中的设备",
"apply": "应用到设备",
"apply_tip": "把最近导出的系数下发到运行中的设备(需再次确认)",
"apply_no_export": "还没有导出的系数文件,请先「导出系数」",
"save_record": "保存标定记录…",
"save_record_tip": "保存本次标定会话的全部点与三轴完整采样(审计数据),与系数文件相互独立",
"save_record_empty": "还没有标定点,无法保存标定记录",
"save_record_ok": "已保存标定记录 {path}\n{points} 个点,共 {samples} 条三轴采样",
"save_record_failed": "保存标定记录失败: {error}",
"open_record": "打开标定记录…",
"open_record_tip": "重新打开已保存的标定会话,检查每个点的完整采样与诊断状态",
"open_record_filter": "标定记录 (*.json)",
"open_record_ok": "已载入 {path}({points} 个点)",
"open_record_device": "记录设备: {device}",
"open_record_warnings": "注意: {detail}",
"open_record_failed": "打开标定记录失败: {error}",
"apply_mismatch_title": "标定与目标设备不匹配",
"apply_mismatch": "该标定文件属于: {device}\n不匹配项: {detail}\n\n当前目标: {sensor_type} / {config}\n\n继续应用会把不适用于本设备的系数写入运行中的设备。",
"apply_mismatch_skipped": "已取消应用(标定身份与目标设备不匹配)",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw_label": "实时原始值",
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
"col_weight": "参考载荷",
"col_fn": "FN 均值",
"col_fx": "FX 均值",
"col_fy": "FY 均值",
"col_stability": "稳定性",
"col_state": "状态",
"col_raw": "原始均值",
"col_n": "n",
"col_std": "σ",
"state_ok": "正常",
"state_excluded": "已排除",
"state_validation": "验证点",
"state_exclude": "排除",
"state_exclude_tip": "把选中点排除出拟合与导出(记录保留,可恢复)",
"state_restore": "恢复",
"mark_validation": "设为验证点",
"mark_validation_tip": "把选中点留作独立验证点:不参与拟合,用于检验拟合",
"excluded": "该点已排除出拟合",
"restored": "该点已恢复",
"marked_validation": "该点已标记为验证点",
"unmarked_validation": "该点已取消验证点标记",
"fit_insufficient": "拟合需要至少 2 个有效点(当前 {count} 个)",
"fit_summary": "导出模型 {channel}: {method}({count} 点 / {breakpoints} 个折点,最大残差 {residual} N)",
"fit_validation": "独立验证 {count} 点,最大偏差 {error} N",
"method_piecewise_linear": "分段线性",
"method_linear": "线性",
"point_title": "标定点 {channel} @ {load} N",
"point_channel": "加载轴",
"point_direction": "方向",
"point_round": "轮次",
"point_duration": "实际采样时长",
"point_duration_value": "{actual} s(请求 {requested} s)",
"point_samples": "样本数",
"point_samples_value": "有效 {valid} / 共 {total}",
"point_axes": "三轴均值 ± 标准差",
"point_residual": "拟合残差",
"col_actions": "",
"recording": "正在采集标定样本…",
"recording_stop_tip": "点击可取消本次标定采集(随时可用)",
"record_ok": "已记录 weight={weight} raw={raw}",
"record_failed_few": "采集失败 — 样本过少",
"export_ok": "已导出 {path}",
"export_local": "同时写入 {path}",
"export_warnings": "跳过: {detail}",
"export_failed": "导出失败: {error}",
"export_no_model": "没有可导出的模型: {detail}",
"apply_title": "应用标定",
"apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。",
"apply_skipped": "已导出,未应用到设备",
"apply_pending": "已请求把标定应用到 {sensor_type},等待设备确认…",
"applied": "设备已确认标定生效(版本 {version})",
"applied_none": "设备已确认:当前没有加载标定(该项留空)",
"export_only": "已导出系数 {path}(未应用到设备)",
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
},
"status": {
"phase_idle": "空闲",
"phase_connecting": "连接中",
"phase_running": "采集中",
"phase_error": "出错",
"rail_video_name": "视频文件: {name}",
"data_valid": "数据有效",
"data_invalid": "无效样本 {count}",
"calibrated": "已标定",
"calibrated_all": "已标定 · 三轴",
"calibrated_axes": "已标定 · {axes}",
"uncalibrated": "未标定",
"config_none": "未选配置",
"camera_none": "未发现摄像机",
"camera_found": "发现 {count} 个摄像机: {names}",
"preview_started": "预览已启动(摄像机 {camera_id})",
"preview_stopped": "预览已停止",
"preview_failed": "预览失败: {error}",
"connect_slow": "连接耗时较长,可点击「取消连接」或按 Esc 中断",
"run_stopped": "已停止",
"run_stopping": "正在停止…(正在释放设备)",
"run_stopping_cancelled": "已取消连接(正在释放设备)",
"sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定",
"samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)",
"run_failed": "启动失败: {error}",
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
"rail_camera_fps": "摄像头 {fps}",
"capture_slow": "摄像头交付的帧率低于配置值:{detail}。记录会按实际帧率保存。这是采集端(摄像头/驱动)的问题,软件改不了:换一颗真能交付 30 fps 的摄像头,或先确认线材/端口没有把它降速,然后再测。",
"video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"reset_pending": "已请求归零,等待采集线程确认…",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
"units_raw_fallback": "无标定数据 — 显示原始单位",
"reveal_failed": "无法打开配置位置: {error}"
}
}
@@ -2,17 +2,24 @@
"language": "zh_CN",
"theme": "dark",
"window_geometry": null,
"window_default_size": [1440, 880],
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"show_cal_panel": false,
"show_finger_3d": true,
"show_fps": false,
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90
"arrow_rotation_deg": 90,
"settings_expanded": false,
"view_preset": "custom",
"splitter_sizes": [340, 480, 400]
}
+419
View File
@@ -0,0 +1,419 @@
# 记录通道与数据可靠性
本文记录 Studio 在"完整记录、数据可靠优先"这轮改动中的架构决定,以及针对架构评审
第 8 节所列缺陷的修法与验证方式。
核心结论是:**标定采样、全程历史、无效样本隔离之所以会出问题,根因是同一处架构缺口
—— 采集数据只有一个出口,就是 16 ms 的 UI 定时器。**
---
## 1. 改动前的问题链
```
worker 线程 ──> self._latest(单槽,后写覆盖)
│
UI 定时器 _on_run_tick(16 ms)──┬──> 曲线历史(只保留窗口内样本)
├──> 标定采样(且被 if self._show_cal 守卫)
└──> 箭头/深度渲染
```
由此派生出评审指出的多个缺陷,它们不是彼此独立的问题:
| 现象 | 机制 |
|---|---|
| 标定依赖面板可见 | `app_window.py` 中 `push_raw` 被 `if self._show_cal:` 守卫,而它是唯一检查录制截止时间的地方 |
| 标定录制中无法停止 | `push_raw` 永不执行 → `_finish_recording` 永不调用 → `_recording` 恒为真 → 而 `_recording` 又让录制按钮禁用。**两次点击都无法脱困** |
| 历史被窗口裁掉 | `MetricHistory._prune()` 主动丢弃 `t < t_end - window_sec` 的样本 |
| 面板未刷新时丢数据 | 单槽 `_latest` 后写覆盖,UI 降帧即丢样本 |
---
## 2. 现在的结构
```
worker 线程 ──> self._latest(显示,可有损)
│
└──> _emit(packet) ──> 记录 sink(记录,不可丢)
│
app_window._on_packet_recorded(运行在采集线程,不碰 Qt)
├──> SessionStore 全程存储
└──> CalibrationRecorder.feed() 标定采样缓冲
UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
└──> CalibrationRecorder.tick() 到点后结算(Qt 回调留在 UI 线程)
```
分工原则:
- **`feed()` 在采集线程调用**,只往线程安全的缓冲里追加,绝不触碰 Qt 控件。因此
隐藏标定面板、曲线重绘变慢、窗口后台化都不会中断记录。
- **`tick()` 在 UI 线程调用**,负责判定截止时间、计算统计量并触发回调 —— Qt 信号与
控件更新必须留在 UI 线程。
- **sink 是普通 callable 列表**,某个 sink 抛异常会被记录并只影响该 packet,不会杀掉采集。
### 2.1 采集循环只认「新帧」,并据此定义帧率
`CaptureSource.read_latest` 返回的是**后台读取线程的最新一帧**:如果那一刻还没有新帧,
它把**同一帧**再给一次。采集循环以前不做判断,于是同一帧被反复处理 —— 30 fps 的设备
录出了 63 行/秒(且 `frame_id` 各不相同,看起来像真的),顶栏的 `packet.fps` 显示的是
**我们自己的轮询速率**。
- `_capture_sequence(sensor)` 读 `capture_source.status.last_seq`:计数没变就是"还没有新
帧",睡 `CAPTURE_REPEAT_POLL_S`(2 ms)后重试,**不进处理链、不发 packet**。
只有带后台读取线程的源才有这个标记;视频文件是同步 `read_raw()`,每次调用都解码下一
帧,因此它的计数与"重复"无关,直接忽略(返回 `None`,行为不变)。
- `packet.fps` 改为**速率**:`(窗口内帧数 - 1) / (首末到达时刻之差)`,窗口 `FPS_WINDOW_S`
= 1 s。按"1 秒内到达了多少帧"数数会系统性地偏大(窗口里正好装一秒,抖动就多算一帧)。
- **等待新帧的间隔必须真的睡得着**:Windows 默认计时器分辨率 15.6 ms,`time.sleep(0.002)`
实际睡 15.6 ms;两拍就跨过 30 fps 的 33 ms 周期,于是 30 fps 的设备被以 16.7 fps 消费
(隔帧丢)。`_high_resolution_timer()` 在会话期间把分辨率提到 1 ms(结束即还原),
且重复帧的等待取 `min(距下一帧的时间, 半个周期)`,已经到期时只 `sleep(0)` 让出而不是
再睡一拍。实测:同样是 30 fps 的假设备,不带 1 ms 计时器时只能消费 21.6 fps,带上即 30 fps
(`shared/tests/test_worker_frame_rate.py::test_a_30_fps_device_is_consumed_at_30_fps`)。
- 这一层是"哪些帧算一帧"与"每秒多少帧"的唯一判据,记录、曲线、标定与 CSV 都跟着它走;
它不改动 `packet.fps` 的语义之外的东西 —— **容器速率**仍然是传感器配置值(见 §6.1)。
`shared/tests/test_worker_frame_rate.py` 用一个假 SDK 驱动真实循环验证:同一帧返回 3 次
时,packet 数 ≈ 交付帧数(去掉判据后是 11317 : 39),30 fps 的假设备必须被以 27–31.5 fps
消费,且 `packet.fps` 与交付速率一致。
**文件源的时间轴是视频自己的**:`source` 是字符串(文件)时,`mono_t = frame_id × 1/fps`
(用配置的容器速率),而不是墙钟。否则把 5 s 的录制以 60 fps 跑一遍会落在 0–2 s 的轴上,
无法与被复核的 `forces.csv` 对齐。实时相机仍用单调时钟。
### 关键文件
| 文件 | 职责 |
|---|---|
| `shared/.../acquisition/sinks.py` | `RecordSinkMixin`:sink 注册与扇出,两个 worker 共用 |
| `shared/.../force/session.py` | `SessionStore`:全程存储,内存有界,双单位 + 有效性 + 帧号 |
| `shared/.../force/calibration_recorder.py` | 标定采样状态机,与 Qt 无关 |
---
## 3. SessionStore 的三条保证
1. **不是"把窗口调大"**:按定长 numpy 块追加,O(1) 写入;达到 `max_samples` 时释放最旧
的整块并累加 `dropped`。`dropped` 由界面显式提示(`status.samples_dropped`),
不允许静默丢数据。
2. **同时保存 raw 与标定后数值**:每个样本存 `(fn,fx,fy)_raw` 与 `(fn,fx,fy)_N` 两套。
切换显示单位时曲线从 store 重建,而不是把新单位的值追加到旧缓冲上。
3. **无效样本标记而不是归零**:读取失败时存 NaN 且 `valid=False`;`iter_valid()` 只产出
有效样本,因此标定统计不可能把一个失败读数当作 0 平均进去。
---
## 4. 针对评审第 8 节的修法
| # | 问题 | 修法 |
|---|---|---|
| 1 | 标定中无法点击停止 | `_refresh_enabled` 改为 `can_record = self._running`,录制中按钮保持可用并显示「停止」 |
| 2 | 切换 raw/N 后历史混用单位 | 新增 `ForcePlotsPanel.set_units(unit, store)`:清空缓冲并从 `SessionStore` 按目标单位重建;时间原点保留,X 轴不会跳回 0 |
| 3 | 异常被当成正常数据 | `read_forces_from_sensor` 不再吞异常:返回 `valid=False` + `invalid_reason` + NaN;`SdkPacket` 新增 `valid` / `invalid_reason` / `raw_available`;无效样本不进曲线、不进标定 |
| 4 | 标定依赖面板可见 | 采样改由 worker sink 驱动;`_show_cal` 只影响可见性,不再作为采样入口 |
| 5 | 历史 FN 被截断 | 去掉写入前的 `max(0.0, fn)` 夹值与显示前的 `np.maximum`;负值原样保留,越界由坐标轴范围裁剪(只影响绘制,不影响数据) |
| 6 | 导出覆盖并自动应用标定 | 导出只写用户标定目录(`local_bundle_dir=None`),不再改写 `config/sensors/<type>/`;应用前弹窗显示目标设备类型与配置,且只下发给匹配的 worker |
| 7 | 深度显示误导 | `depth_map_to_bgr` 支持 `value_range` 固定色标与 `invalid_mask`;色标在会话内锁定到首个有效深度帧并在面板头部显示数值范围;无深度时显示明确提示,**不再回退成普通形变图** |
| 8 | 历史只保留窗口内样本 | 见 §3;`SessionStore` 与显示用的 `MetricHistory` 分离 |
另外修正了评审未列出的一处:RAW 通道不可用时,原代码把标定后的 N 值复制进 raw 字段
(`fn_raw = fn`)。现改为 `raw_available=False` 且 raw 保持 NaN,避免"原始值"实际上是
标定值。
---
## 5. 验证
```powershell
$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
cd D:\orisys_ws\orisys-app\apps\studio
& $py tools/ui_check.py # 含 "record channel" 段落
```
- `shared/tests/test_record_channel.py`:11 项,覆盖内存上界、双单位、无效隔离、
跨线程 feed、截止时间结算、取消语义。
- `shared/tests/test_experiment_recorder.py`:12 项,覆盖目录结构、CSV 列序与空值规则、
帧号/时间基准一致、分段滚动与帧索引、仅数据模式、无损序列、队列溢出计数与报警、
编码失败上报并停止录制、元数据字段、崩溃后仍留有元数据、`on_packet` 不阻塞。
- `shared/tests/test_worker_cancellation.py`:4 项,覆盖非阻塞 stop、线程所有权、
会话交接。
- `tools/ui_check.py`:新增 `record channel` 与 `experiment record` 两段,断言记录通道
在**完全没有 UI 定时器触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用
取消入口、深度缺失不回退、色标锁帧,以及通过界面录制的目录结构与事件序列。
- `apps/studio/tests/test_ui_smoke.py`:上述不变量各有对应 pytest 用例,复用
`ui_check._StuckWorker` 与 `ui_check._fake_packet`,保持单一来源。
### 已知环境限制
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
覆盖同样的不变量且可完整运行。
---
## 6. 实验录制(第一阶段剩余部分)
`shared/src/orisys_app_shared/record/experiment.py` 的 `ExperimentRecorder` 落盘格式:
```
outputs/recordings/<UTC 时间戳>_session<NNNN>/
session.json 元数据 + 事件 + 结束统计
forces.csv 每帧一行
frames.csv 帧索引映射(帧号 ↔ 视频分段)
events.csv 带时间戳的操作事件
raw_0001.mp4 处理前相机图像,按需分段
frames/ 可选无损 PNG 序列
```
`forces.csv` 列序固定为
`session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N, valid, calibration_id`。
几条硬性约束:
- **一次实验一个目录**,`session.json` 在**开始时先写一次**,因此即使进程崩溃也留有可识别
的元数据;停止时再写一次并补上 `ended_at`、事件与统计。
- **不静默丢数据**:`on_packet` 只把标量(以及开启视频时的一帧拷贝)放进**有界队列**,
队列满则计数并报警,绝不阻塞采集;连续写失败达到 `MAX_CONSECUTIVE_ERRORS` 就停止录制
并把 `failed` 置位,而不是假装录完了。
- **帧号与时间统一**:`forces.csv` 与 `frames.csv` 共用 packet 的 `frame_id`,`t_rel_s`
同源于首帧;`frames.csv` 额外记录采集墙钟时间、落在哪个分段、以及是否进过视频,
这就是丢帧/分段场景下的重建依据。
- **未标定或读取失败的单元格留空**,不写 0:`FN_N` 等列在无标定时为空,`valid=0` 的行
所有数值列都为空。精度用 `repr(float)`(最短往返表示),界面小数位不影响落盘。
- **MP4 是有损封装**:`session.json` 的 `video.lossy_note` 明确记录这一点,需要算法复现时
可开启 `lossless_frames` 输出 PNG 序列。
- `raw_frame` 在入队时**必须拷贝**:packet 里的帧可能复用 SDK 的采集缓冲,直接持有引用
会让录制内容随后续帧改变。
### 6.1 录制速率与时长(视频 30 fps、时长实时递增)
`session.json` 的 `video.fps` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置
(`config/sensors/<type>/sensor.json` 的 `fps`,box 为 30),**不是** packet 里那个
`fps` 字段 —— 后者是"本机每秒处理了多少帧"的实时速率(见 §2.1),开流瞬间的缓冲突发会
让它高于配置值,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制
结果 48 fps"的直接原因。
- `StudioMainWindow._video_fps()`:实时相机 → 传感器配置的 `fps`;视频文件源 → 实测值;
实时相机的实测值另存 `video.measured_fps`,两者都进 `session.json`,对不上时看得见。
- **视频按速率节流,CSV 一行不少**(`ExperimentRecorder._video_frame_due`):比
`1/fps` 提前超过 `VIDEO_PACING_TOLERANCE`(0.5 个周期)到达的帧**只进
`frames.csv` / `forces.csv`,不进视频**,并计入 `stats.resampled_video`;报告里会写成
"N frame(s) were delivered faster than the 30 fps recording rate, so they are in
frames.csv but not in the video"。这不是丢数据,是显式记账。
- 节流表的调度基准取"上一帧排定时刻 + 周期"与"当前时间戳"的**较晚者**,所以卡顿之后不会
用一串补帧追进度。
- `stats()` 的 `duration_s` 在录制**进行中**就返回 `_last_t - _t0`(此前只有 `_finalise`
才写,界面上的录制时长因此整场停在 00:00.0);`forces.csv` 的行数与时间轴不受影响。
### 已知环境限制
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
覆盖同样的不变量且可完整运行。录制相关的测试把临时目录放在
`shared/tests/_scratch/`(已加入 `.gitignore`)以避开该限制。
---
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
评审脚本 `D:/orisys_ws/_review_phase12/reproduce.py` 的十条复现条件已全部翻转,改后
输出见同目录 `reproduce_after.log`。以下按发现逐条说明改法。
### 7.1 记录语义(P1-1)
唯一转换入口 `shared/.../record/sample.py::to_record_sample`。规则:
1. 计算链失败(`valid=False` 或 `stale_forces=True`)的帧,六个力值**全部**记为不可用。
SDK 的 `info.FNORMAL` 在计算失败时仍返回上一帧的数值,记录它等于伪造测量值。
2. 只有**该轴确实加载了标定曲线**才写 N 值(`calibrated_axes` 按轴判断,而不是
`force_calibrated` 这个传感器级标志)。未标定的轴留空。
3. `mono_t` 是排序基准,墙钟只进元数据。`SdkPacket.mono_t/wall_t` 默认 `None` 而不是
`0.0`:默认 0 会让所有帧的时间相同。
4. 每行 `calibration_id` 取 **packet 自带版本**(采集线程在采集瞬间盖的哈希),
会话级版本只作回退;一份 CSV 中途换标定也说得清。
### 7.2 记录终态与队列损失(P1-5、P1-6)
- 生命周期显式化为 `starting/recording/stopping/stopped/failed`;**写线程拥有终态与最终
报告**,文件打开、写入、flush、close 全在异常边界内。连续写失败会立刻停止接收
(`rejected_after_failure` 计数),`is_recording` 随之变假 —— 不会出现 failed=True 却
仍在录制。`starting` 也算“录制中”,否则开始录制后立刻点停止会失效。
- `stop(wait=True, timeout=…)` 超时返回 `completed=False` 的报告,**不写 `ended_at`**、
不丢弃线程引用;未结束的 recorder 不允许再次 `start()`。
- 队列按**字节预算**(`max_queue_bytes`,默认 512 MiB)而不是仅按条数:1920×1080 BGR
排 2048 条副本约 11.9 GiB。预算用尽时**丢视频保数据行**;连条目队列都满时把数据行放进
独立的 `_row_backlog`(默认 8192 行),因此视频溢出不再连带丢掉 CSV。
- 真的丢掉的数据行会在 `frames.csv` 留下 `dropped=1` 的索引行,回放据此显示“该帧未录制”,
而不是拿上一帧冒充。统计区分 `dropped_data`、`dropped_video`、`dropped_gaps`
(已标记的缺口)与 `unrecorded_gaps`(连标记都放不下的)。
### 7.3 标定身份与生效确认(P1-7)
- 标定身份是**内容哈希**(`calibration_digest`,sha256),不是路径:同一路径可以重新导出
不同系数。`Sensor` 打开时自带 bundle 标定,因此采集线程在**第一帧之前**就把实际加载的
标定哈希报出来(`on_calibration_applied(path, version, frame_id)`),事件与每行版本都
来自这次确认,而不是“已下发”的意图。
- 事件在**发生时**打时间戳(`log_event(..., mono_t=…, frame_id=…)`),不再在写线程出队时
重新计时。
- 面板拆成三个独立动作:**保存标定记录 / 导出系数 / 应用到设备**。导出只写文件,
应用需要再次确认并等设备确认后才记 `calibration_applied`。
### 7.4 标定诊断与实验明细(P1-2、P1-8)
- `CalibrationRecorder.fit` 用**将要导出的那个模型**(`build_export_model` + SDK 的
`Calibration.convert`)计算残差与验证误差。分段线性模型会精确穿过它自己的标定点,
所以训练残差恒为 0 —— 真正有意义的诊断是**留出验证点**的偏差。界面按“导出模型:分段线性
(N 点 / M 个折点,最大残差)…”显示。
- 验证点**不进入**构建(`CalPanel._rebuild_store` 跳过 `is_validation`),重复参考载荷按
n 加权合并(`merge_samples`),不再“最后一个覆盖前面的”。
- `CalibrationPoint.samples` 保存该点**全部**三轴采样(帧号、相对时间、三轴 raw、三轴 N、
valid),连同排除/验证状态、方向、备注与实际时长一起写入独立的标定会话文件
(`calibration_session_*.json`,`force/calibration_session.py`),并可用「打开标定记录」
重新载入检查。界面上的降采样 trace 只是预览,不是审计数据。
### 7.5 深度重算引擎(已删除)
这一节记录的 `DepthRecomputer` / `depth_backends`(离线把录制视频喂回 SDK 重算深度、按
帧号增量读、归零/预卷恢复算法状态、请求绑定帧号)**已整体删除**:用户明确不需要"为回放再
录/再算一遍深度"这条路径,回放视图只放录制的原始相机图像。要复核算法的当前做法是
「视图 → 由视频重算力值」(把录制视频跑一遍采集管线,见 `docs/shell-lifecycle.md` §2.1),
它复用实时那条链路,因此不再需要一份离线复现实现。
`session.json` 里的深度块保留(它说明"深度没有保存"这一事实),但 `replay` 字段改为
`"none"`;`RecordingReader.replay_warnings()` 不再报 `depth_recomputed`。
---
## 7. 同步回放与深度(不保存深度)
**决定:不保存深度。** 理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,
一小时就是 ~16 GB;即使降频到 1 Hz 也要百兆级。所以 `session.json` 里明确写:
```json
"depth": {
"stored": false,
"replay": "none",
"quantity": "relative deformation / algorithm response, not a calibrated length",
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
"config_sha256": "...",
"sdk_version": "..."
}
```
因此 `RecordingReader.replay_warnings()` 会比对**当前配置文件哈希**与录制时的哈希、以及
SDK 版本,「由视频重算」前这些提示必须让操作者看到(回放视图里放在来源摘要的 tooltip 与
一条 6 秒通知里):
| 提示 | 含义 |
|---|---|
| `config_changed` | 配置文件已变,由视频重算的结果可能与当时不同 |
| `sdk_changed` | SDK 版本已变,重算结果可能不同 |
| `config_missing` | 缺少配置哈希,无法校验一致性 |
| `rows_skipped` | 有无法解析的 CSV 行被跳过 |
| `frames_dropped` | 有帧在队列溢出时丢失,只留索引行 |
| `out_of_order` | 旧版本写入的行序不是时间序 |
### 回放界面
回放**与实时是同一套界面**:`ui/playback_dialog.py`、`ui/replay_bar.py` 与深度重算引擎都
已删除。`replay.py::ReplayController` 只读录制并发信号,主窗口把它接到同一套面板上——
状态轨的主按钮在回放视图里变成「▶ 回放 / ⏸ 暂停」(`toolbar.set_run_role`),来源槽换成
「选择录制视频…」。布局、单位、看点与关闭流程见 `docs/shell-lifecycle.md` §2
(含「由视频重算」复核模式)。
- **一个游标驱动三处**:移动位置同时更新视频帧与三张曲线的游标线;并且通过 `cursor_moved`
信号同步到主界面的曲线面板。
- 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。
- **回放按录制实际有的单位打开**:录制里 `FN_N` 等列全空(没有标定)时退回 raw,
否则选 N 会画出三张空图;`raw` 一定存在(那是设备测到的值)。
- **数值卡读的是游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`):浏览历史或回放
时,三个大数字是播放头所指的样本,而不是实时采集的最后一个样本 —— 否则一张 7.5e5
原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。
实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
- **解码器常开**(`RecordingReader.video_frame_at`):以前每帧重开文件 + seek,1080p 下
每帧上百毫秒,30 fps 播放时视频根本不动;现在按分段缓存解码器、顺序帧 `read()` 步进
(实测 0.5 ms/帧),跳转才 seek。
两个必须知晓的边界:
1. SDK 打开视频逐帧步进**可能长时间阻塞**,且无法取消。因此用守护线程:窗口能关、
进程能退,但那个线程会一直挂到进程结束。用线程池(非守护)会让**整个进程无法退出**,
这是实测踩到的。
2. 重算失败一律显示原因(SDK 不可用/未记录配置/配置路径不存在/无视频分段/
帧不在视频中),**绝不用其它图像顶替**。
---
## 8. 长历史浏览:按需读盘而不是整段载入
`record/index.py` 的 `RecordingIndex` 是回放的数据源索引:
- 流式扫一遍 `forces.csv`,只把**小东西**留在内存:每行一个时间(float64)、一个
valid 标志、一个帧号,以及每 `CHECKPOINT_ROWS`(4096)行的字节偏移。500k 行约
6.5 MB(`SessionStore` 的默认上限正好对应 30 Hz 下约 4.6 小时)。
- `row_range(t0, t1)` 用二分定位行区间,`read_rows(start, stop)` 先 `seek` 到最近的
检查点再向前扫描——读一个 10 秒区间就是 10 秒的解析量,不是整段。
区间会额外带上 `t0` 之前的那一行,绘制时才能画出跨过左边界的线段。
- `overview(buckets)` 在**同一次流式扫描**里把每行折进所属桶,得到每桶每轴的
min/max/count。全程色带因此不会漏掉某桶里的尖峰(按行抽样会漏)。
`ReadingReader` 相应变化:
| 方法 | 作用 |
|---|---|
| `info()` | 行数超过 `max_rows`(默认 200k)时给出**降采样视图**,并置 `decimated=True`;`duration_s` 始终取自帧索引,不取自视图 |
| `window(t0, t1, max_points=…)` | 区间精确读取(可再降采样),尖峰按轴保留 |
| `overview(unit, buckets)` | 全程包络,按 (unit, buckets) 缓存 |
| `statistics(t0, t1, unit)` | 只算该区间的统计量 |
`force/source.py` 把两种来源统一成同一接口:`StoreCurveSource`(实时内存块)与
`RecordingCurveSource`(磁盘区间 + 缓存包络),都提供
`extent() / window() / envelope() / statistics() / duration_s()`;`ForcePlotsPanel`
只认这个接口,因此同一套浏览逻辑既能看内存会话也能看磁盘录制。
回放窗口在曲线被降采样时会明确写出「显示 N / 共 M 行」,而不是让操作员自己数点。
> 未做的验收项:真实分辨率与目标 ARM 板上的最大 RSS、积压与丢失测试(需要硬件),
> 以及选中时间段的 CSV 导出。
---
## 9. 界面上的记录状态条
录制过程对操作员可见的部分,集中在顶栏状态行右侧的一个记录状态条
(`apps/studio/src/orisys_studio/ui/record_status.py`)。它回答按钮本身答不了的四个问题:
| 显示 | 判据 | 颜色令牌 |
|---|---|---|
| `尚未记录` | 本会话从未开始记录(`recording.is_recording` 与 `last_report` 都为空) | `status_idle` |
| `记录中 mm:ss.d` | `recording.is_recording`;时长取自 `recorder.stats().duration_s` | `status_running` |
| `正在写入…` | `recording.report_pending`:写入线程还没关完文件(**不**当成已完成) | `status_connecting` |
| `上次记录 N 帧` | 已拿到终态报告且 `failed`/`write_errors` 为 0 | `status_idle` |
| `写入异常(N)` | `stats.failed` 或 `stats.write_errors > 0` | `status_error` |
丢帧不是单独一行,而是追加在状态后面(`丢帧 N`),并把整条状态提到 `warning` 色;
N 是 `dropped_data + dropped_video + dropped_gaps + unrecorded_gaps` —— 只报前两个
队列溢出计数会低估真实损失。tooltip 里分项列出帧数、分段、时长、四类丢帧、写入错误、
最近一次错误与输出目录,因此状态条本身可以保持一行。
读数入口是 `StudioMainWindow._record_stats()`:它在 UI 线程上返回
`(state, stats, directory)`,是**唯一**读记录通道的地方。`ExperimentRecorder.stats()`
返回加锁快照,不会被写入线程阻塞;`tools/ui_snapshot.py --scene recording` 就是替换这
一个接缝来渲染「记录中」画面,既不写文件也不开设备。
刷新时机:`_on_run_tick`(16 ms 定时器)与每次状态跃迁(开始/停止记录、拿到终态报告、
阶段变化)。控件只在文本或颜色令牌真的变化时才 `setText`/`setStyleSheet`,因此 60 Hz
下不会反复重排顶栏。
---
## 10. 尚未完成(第三阶段及以后)
- PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
- 标定的稳定后采样(当前按固定时长采样,稳定性以 σ 呈现)。
- 在目标 ARM 板上用真实分辨率测长时录制的最大 RSS、队列积压与丢失。
+240
View File
@@ -0,0 +1,240 @@
# Studio 外壳:视图与关闭流程
这份文档记录主窗口的三件"外壳"行为:三个顶层视图如何切换面板、回放如何并入主窗口
(含可拖拽进度条),以及窗口关闭后进程如何回到命令行。它们都不属于记录通道,但都会
直接影响可用性。
---
## 1. 三个顶层视图 = 同一窗口的三套布局
品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局
(`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
| 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|---|---|---|---|
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `0, 360, 1080, 0` |
| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
| 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
- **为什么标定视图给标定面板 880 px**:六列诊断表头加九个动作按钮,在 300–400 px 的
三栏布局里必然被挤压。标定面板自身还有纵向滚动区,窗口再矮也够得到「应用到设备」。
- **`custom` 不点亮任何导航项**:手工摆出来的布局不属于三个视图中的任何一个。
- **`视图 → 标定面板` 与「标定」导航是同一个开关**:勾上进入标定视图,取消回到实时视图。
- **布局菜单里的「自由布局 / 二维模式」已删除**:导航视图 + 分割条已经覆盖它们,而
「深度图 + 曲线」由 2D 面板自带的「深度图」模式一键完成;`layout_preset` 偏好一并移除。
### 1.1 采集来源随视图,不再是「来源」下拉
`ToolbarWidget` 的状态轨上有一个来源槽(`source_slot`):
| 情形 | 轨上显示 |
|---|---|
| 实时监测 + 摄像机 | `摄像机 [编号] [搜索]` |
| 实时监测 + 视频文件 | `使用摄像机` 按钮 + 摘要里的 `视频文件: xxx.mp4` |
| 历史回放 | 「选择录制视频…」按钮 + 摘要里的视频名 |
「用视频文件跑一次采集」仍从「文件 → 打开视频…」进入,`source_mode` / `video_path`
偏好保留,但不再有一个需要先选「来源」才能用的下拉。
### 1.2 传感器与标定共用一个弹窗
轨上只有一个摘要按钮(`BOX · box/sensor.json`,`ToolbarWidget.btn_sensor`);
点击打开 `apps/studio/src/orisys_studio/ui/sensor_dialog.py::SensorDialog`:
- 传感器类型 / 传感器配置两个下拉(原 `SensorPickerWidget`,仍是窗口的属性,
因此 `sensor_type()` / `config_name()` 与信号语义不变);
- 「当前生效的标定」:设备确认的版本(`_active_calibration_id`)与已标定轴;
- 「最近导出的系数」:文件、大小、时间与文件身份(类型 / SN / 配置,来自
`calibration_identity()`);导出/应用按钮转调 `CalPanel.export_coefficients()` /
`apply_coefficients()`,实现只有一份。
弹窗常驻一个实例、每次打开 `refresh()`,所以设备刚确认的标定立刻可见。
---
## 2. 回放与实时是同一套界面(只换来源)
「历史回放」不另开窗口,**也不另起一套控件**:它与「实时监测」是同一套布局、同一条状态轨、
同一套图表手势。**唯一的差别是左上的来源槽**——实时是摄像机,回放是一段录制视频。
`ui/replay_bar.py`(进度条 + 录制目录选择)与 `PlaybackDialog` 都已删除。
- `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、
回放提示(降采样 / 跨版本)。它不画任何东西,只发信号:`position_changed` /
`frames_changed` / `status_changed` / `recording_changed`。深度重算引擎
(`DepthRecomputer` / `depth_backends`)已随回放条一起删除——用户不需要这条回放路径,
要复核算法就用「由视频重算」。
- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与
「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录,
于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。
- **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw,
否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。
表头的单位芯片是唯一的单位开关(`_set_display_unit` 同时告诉曲线、工具栏与控制器)。
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像」一项
(`REPLAY_VIEW_MODES`;录制里没有光流,也没有存储深度),页脚显示「录制视频(原始相机
图像)」并隐藏放大倍率与有效区域。
- **游标就是播放头,运输控制就是状态轨上那颗主按钮**:回放视图里
`toolbar.set_run_role("playback")` 把它换成「▶ 回放」/「⏸ 暂停」(实时视图仍是「运行」),
以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会**自动暂停**
(`ForcePlotsPanel.timeline_taken_over` → `_pause_playback`)。空格键同效。没有独立进度条,
曲线表头也不再放第二个播放按钮(一个运输控制,不会两处打架)。
- **视频解码器常开**:`RecordingReader.video_frame_at` 以前每帧 `VideoCapture` 重开 + seek
(小片段 10 ms,1080p H.264 上百 ms),30 fps 播放时每一拍都卡在 UI 线程上,视频看起来
根本不动。现在按分段缓存一个解码器,**顺序帧用 `read()` 步进**(实测 0.5 ms/帧),
跳转才 seek,`RecordingReader.close()` 负责释放。
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
- **2D 面板归当前视图的来源所有**(`_replay_owns_image()`):回放视图打开录制后,采集线程
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
`_render_run_image` 直接返回。否则回放画面会被摄像头帧一帧一帧盖掉("回放时好像用的是
摄像头")。切回实时视图才把面板交还给实时帧。
- **回放提示不占位置**:降采样 / 跨版本等说明放在来源摘要的 tooltip 里(状态栏只留一次性
通知,见 §2.2),打开录制时另发一条 6 秒通知。
- 离开回放视图:曲线挂回实时 store 并恢复跟随,来源槽换回摄像机,**已打开的录制保留**,
摘要里的视频名**不再出现**(这正是"视频文件名残留在实时检测里"的修法)。
- 文件菜单两条入口(`打开录制视频…` / `回放最近一次录制`)都只是切到回放视图并加载录制。
### 2.1 「由视频重算」:算法复核模式
`视图 → 由视频重算力值`(`act_recompute`)把曲线的来源从**录制的 CSV** 换成**把录制的视频
重新跑一遍视觉管线**:
| | 默认(关闭) | 打开 |
|---|---|---|
| 曲线数据 | 录制目录的 `forces.csv`(当时测得的值) | 管线在视频上重算出的样本(进 `SessionStore`) |
| 2D 面板 | 录制视频在游标处的那一帧 | 管线正在处理的帧 |
| 时间轴 | 任意定位(游标、框选、总览条、播放) | 只能顺序前进(管线是有状态的),播放按钮即开始/暂停这一遍 |
| 用途 | 看当时的记录 | 复核算法版本(mp4 有损,数值与原记录会有差异) |
打开它时会立即开始这一遍(`_on_recompute_toggled` → `force_plots.set_playing(True)` →
`_on_transport_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次);
关闭时停止该遍并挂回 CSV。`_replay_owns_image()` 在这一遍运行期间为 False,2D 面板交给
管线自己画。
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
统一后的回放布局(不写业务数据,也不需要硬件)。
### 2.2 状态栏与来源摘要的分工
- 状态轨的来源槽按视图切换:实时 = 摄像机编号 + 搜索;回放 = 「选择录制视频…」。
`source_summary` 在回放视图里写「视频文件: xxx.mp4」,在实时视图里**只**写帧率——
视频名出现在实时视图里就是残留。
- 底部状态栏只放一次性通知(见 §1 表格与 `_NoticeBar`),回放提示与完整路径放在来源摘要的
tooltip 里。
### 曲线缩放与默认模式
- 三条曲线在**所有**模式下 `mouseEnabled(x=True)`:滚轮缩放、左键平移。手势信号用
pyqtgraph 的 `sigRangeChangedManually`(只有真实滚轮/拖动才会发),不是
`sigXRangeChanged`——后者在窗口缩放沿 X 轴联动传播时也会发,会把"缩放窗口"误判成
"用户开始浏览"。y 轴仍按配置的固定量程。
- 浏览模式下共享光标线可拖动,拖动即移动播放头(回放视图里就是回放位置)。
- 启动、新一次运行、切换单位/传感器、切回「实时监测」都保证回到「跟随实时」;
只有打开录制后的回放视图才处于浏览状态。
### 曲线表头:动作是图标,单位是开关
表头右侧三个动作(`ForcePlotsPanel._header_button`)只画符号、不写字,符号由
`apps/studio/src/orisys_studio/ui/icons.py` 在运行时用当前主题的颜色绘制
(`zoom_fit` 取景框+角标、`box_zoom` 虚线框、`latest` 箭头进入竖条),因此**没有新增色值**,
换主题时按令牌重新着色,也不会出现位图在不同底色上发灰:
| 动作 | 符号 | 文字(tooltip / 无障碍名) |
|---|---|---|
| 全程总览 | `zoom_fit` | `chart.overview` |
| 框选 | `box_zoom` | `chart.box_zoom` |
| 返回最新 | `latest` | `chart.return_latest` |
- **符号不替代语义**:每个按钮都带 tooltip 与 `accessibleName`,`ui_check.py` 的
`chart header and unit chip` 段逐项断言 `text()==""` 且这三样齐全——只有符号没有说明
等于把动作藏起来。全程总览**区域名**(`window_caption`)仍是文字,它标注的是一块区域。
- **选区统计在数值卡上,不在下方**:框选/缩放出一段窗口后,每张卡的大号数字变成该窗口的
**峰值**、下方小字是该窗口的**均值 · σ**(`chart.selection_detail`),卡片的 tooltip 说明
大数字是峰值;曲线下方只剩「选区 4.13 s / 183 点」(完整的一行仍在它的 tooltip 里)。
以前把三通道的峰/均/σ 塞在一行 `QLabel` 里,中文长句直接把面板顶出右边界。
窗口等于整个会话(「全程总览」)不算选区——那是默认适配,此时卡片回到"游标处样本"。
- **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换
`N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远
一致。旧写法只有「raw/N」两个字,看起来像只读状态。
- 箭头图页脚的放大倍率写成「箭头按位移放大 ×2」,不再只有一个「×2」。
### 状态栏是一条通知行,不是第二条状态行
底部 `QStatusBar` 只承载**一次性通知**(录制结束/失败、标定已应用、连接慢、错误原因):
- **没有常驻控件**。以前右下角有一条 `配置 sensor.json`(`status.device_label`),而状态轨
的传感器摘要按钮已经写着 `MINI · sensor.json`,右下角是同一件事实的第二遍。
- **消息会过期**(`_NoticeBar.DEFAULT_TIMEOUT_MS` = 6 s)。Qt 的 `showMessage(text)` 默认
`msecs=0`,意思是"永不消失",于是会话刚开始那句「运行中(0)」一直挂到退出;只读状态
的读法应该在顶栏(阶段芯片 + 来源槽),所以子类把"没有超时"一律换成 6 秒。
- **与状态轨重复的两条提示被删掉**:`status.run_started`(「运行中(0)」)与
`status.connecting`(「正在连接…」)在轨上分别有「采集中」芯片与相机编号、以及
「连接中」芯片。
- **逐帧重复的通告只发一次**:`status.units_raw_fallback`(无标定 → 显示原始单位)以前每
一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。
### 单位切换是"开关",不是"重新解释"
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
raw;标定一到位就切到 N —— 这一次切换**丢弃**缓冲区里的旧单位样本(会话存储仍然保留
它们,`RecordingCurveSource` / `SessionStore` 可重建),而不是把 raw 计数画到牛顿轴上。
旧写法只把 `_unit` 记成新单位、样本原样留着,于是每次开始采集的第一个画面都是一根
~1e5 的尖峰。触发切换的那一帧本身仍然会刷新数值卡(早期实现里它被 `return` 吃掉了),
而浏览/回放时卡片改为按新单位重读游标处的样本。
同样地,`_start_run` 现在**先** `session_store.reset()` 再 `force_plots.set_store(...)`:
反过来的话新会话开局会带着上一次会话的尾巴。
切换单位后(`set_units`)立即按新单位重读游标处的样本,因为浏览/回放时数值卡说的是游标,
不是实时样本——否则换一下单位,卡片会短暂显示上一次实时读数。
---
## 3. 关闭流程:窗口关掉后进程必须回到命令行
关闭顺序(`StudioMainWindow.closeEvent` → `main()`):
1. `closeEvent`:
- 先摘掉记录通道的 sink,避免正在收尾的采集线程继续持有窗口;
- `_stop_run(reason="user_stop", wait=True)`:阻塞地释放设备;
- `_stop_recording(wait=True)`:写出 `session.json` 并关闭 MP4/CSV;
- `_persist_prefs()`:保存窗口几何、分割比例、视图与各项开关。
2. `app.exec_()` 返回后,`main()` **立刻结束进程**:
```python
code = int(app.exec_())
logging.shutdown()
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except Exception:
pass
os._exit(code)
```
**为什么用 `os._exit`**:相机 SDK 与 OpenCV 各自持有原生线程与句柄,正常返回会在它们之上
再跑一遍解释器收尾(模块销毁、DLL 卸载),进程就卡在那里——症状正是"命令行不会完全退出"。
所有需要落盘的东西在 `closeEvent` 与 `logging.shutdown()` 里已经写完,这里跳过的只是
"已经没有东西可写的库"的析构。
`tools/ui_check.py` 的 `clean shutdown` 段在子进程里跑真实的 `main()`,用定时器关掉窗口
并断言进程在 45 秒内以 0 返回(实测约 2 秒),所以这条路径有回归保护。
---
## 4. 复核方式
```powershell
$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
cd D:\orisys_ws\orisys-app\apps\studio
& $py tools/ui_check.py # 含 top area / views / instrument panels / replay / clean shutdown
& $py tools/ui_snapshot.py --demo --scene replay --out outputs/_snap_replay.png
& $py tools/ui_snapshot.py --demo --preset calibration --out outputs/_snap_calibration.png
```
+273
View File
@@ -0,0 +1,273 @@
# Orisys Studio 配色规范
本文件记录客户端的品牌配色体系、它与 UI 设计稿的关系,以及每一处有意偏离设计稿
的理由。所有色值都存放在主题令牌文件里,控件代码中**不允许出现十六进制颜色**:
```
orisys-app/shared/src/orisys_app_shared/theme/colors_dark.json
orisys-app/shared/src/orisys_app_shared/theme/colors_light.json
```
规则由 `apps/studio/tools/ui_check.py` 的 `audit_palette()` 机械校验,pytest 套件
(`tests/test_ui_smoke.py::test_palette_rules_hold`)复用它,因此只有一个真值来源。
---
## 1. 设计稿色号 → 令牌映射
| 设计稿 | 角色 | 落地令牌 |
|---|---|---|
| `#003893` | 品牌主色 / 底图色 | `accent`、`plot_fn`(浅色);`arrow_canvas` 使用了更深的 logo 藏青 |
| `#4D62BE` | 交互蓝 | `accent_hover`(浅色)、`accent`(深色)、`focus_ring`(浅色) |
| `#12315D` | logo 实际藏青(见 §3.1) | `brand_bar_bg`、`arrow_canvas`(浅色) |
| `#FA02F3` | 洋红 | `flow_ramp` 第 3 档(原值保留) |
| `#FE5853` | 珊瑚 | `flow_ramp` 第 4 档(提亮为 `#FF8A6B`,见 §3.2);`danger` 家族取自同一色相 |
| `#D8FF00` | 柠檬绿 | `flow_ramp` 第 5 档(原值保留)、`plot_fy`(深色) |
| `#D5D5D5` | 应用画布 | **未采用**,改为 `#E1E6EF`(见 §3.3) |
### 1.1 三轴分类色(蓝 / 橙 / 绿)
界面改版把三个力通道统一成"蓝 = 法向、橙 = 切向 X、绿 = 切向 Y"。它们是
**分类色**(三条曲线各自一个身份),与 §4 的箭头色带(顺序色带)是两件事,因此落在
`plot_fn` / `plot_fx` / `plot_fy` 三个令牌上。这三个令牌同时被实时曲线、录制回放、
标定面板与标定点详情对话框引用,换值即全局统一。
| 主题 | `plot_fn`(法向,蓝) | `plot_fx`(切向 X,橙) | `plot_fy`(切向 Y,绿) |
|---|---|---|---|
| 深色 | `#6FA8FF` | `#FF9A4D` | `#7CE0A0` |
| 浅色 | `#003893`(品牌藏青,见 §3.1) | `#B45309` | `#2E7D32` |
浅色主题的 FN 保持品牌藏青 `#003893`(与 `accent` 同值),这样品牌蓝在三轴里仍是
"主通道";橙色与绿色在浅底上取深色相,才够对比度。深色主题的霓虹洋红/柠檬绿被替换,
原因是它们是**色带**用色:作为分类色时洋红与橙的色相差偏小,而柠檬绿在浅底上不可用
(§2.1)。替换后三个通道在灰度打印下也靠亮度分开,不再依赖单一色相。
---
## 2. 实测对比度(WCAG)
对比度用标准相对亮度公式实算(`tools/ui_check.py` 中的 `contrast_ratio`)。
正文门槛 4.5:1,图形/控件/状态指示门槛 3.0:1。
### 2.1 设计稿本身的实测结果
| 组合 | 比值 | 判定 |
|---|---|---|
| 白字 on `#003893` | 10.57 | AAA ✅ |
| 白字 on `#4D62BE` | 5.52 | AA ✅ |
| `#4D62BE` on `#D5D5D5` | 3.76 | 图形达标,12–13px 文字**不达标** ⚠️ |
| `#D5D5D5` vs 白卡 | 1.47 | 卡片层次偏弱 ⚠️ |
| `#D8FF00` on `#D5D5D5` | 1.27 | 不可用 ❌ |
| `#D8FF00` on `#003893` | 9.17 | 极佳 ✅ |
| `#FA02F3` on `#003893` | 3.20 | 达标 ✅ |
| `#FE5853` on `#003893` | 3.39 | 达标 ✅ |
| **`#FA02F3` vs `#FE5853`** | **1.06** | **色带中段的亮度平台 ❌** |
| 白字 on `#FE5853` | 3.12 | 珊瑚色不能承载白字 ❌ |
设计稿的色相选择是可靠的;三个霓虹色的**相对亮度**为 0.268 → 0.287 → 0.861,
色相沿 302° → 2° → 69° 单调前进 127°,作为"位移幅值 → 颜色"的顺序色带是正确用法。
问题在于中段:洋红与珊瑚亮度几乎相同(1.06:1),只靠 60° 色相差区分,在灰度
打印与色觉障碍下会糊成一片。
### 2.2 落地后
浅色主题(节选):白字 on `accent` 10.57,白字 on `accent_hover` 5.52,
`plot_fn/fx/fy` 对 `plot_bg` 分别 10.11 / 4.80 / 4.90,
`brand_bar_fg` on `brand_bar_bg` 12.94,`input_border` 对四种底均 ≥3.08。
深色主题(节选):白字 on `accent` 5.52,`accent` 对 `panel_bg` 3.09(贴线但仍达标),
`plot_fn/fx/fy` 对 `plot_bg` 分别 7.53 / 8.62 / 11.25,`focus_ring` 对 `input_bg` 6.51。
语义色(`danger` / `warning` / `success` / `status_*`)额外对 `toolbar_bg` 审计:它们会
出现在顶栏状态行上(记录状态、数据有效性、阶段指示),实测浅色 4.65–6.66、深色 5.60–9.13,
全部 ≥4.5。在这一步之前它们只在 `panel_bg` / `card_bg` 上被检查,顶栏是一个未经审计的底色。
三通道可分辨性(色相差 / 相对亮度比):
| 主题 | FN vs FX | FN vs FY | FX vs FY |
|---|---|---|---|
| 浅色 | 168.8° / 2.10 | 94.1° / 2.06 | 97.1° / 1.02 |
| 深色 | 169.7° / 1.14 | 74.7° / 1.49 | 115.6° / 1.31 |
规则是"色相差 ≥90° **或** 相对亮度比 ≥1.2",两主题均满足(深色 FN vs FY 靠 1.49 的亮度比
过关,浅色 FX vs FY 靠 97.1° 的色相差过关)。颜色在任何情况下都不是唯一编码:每个曲线有
文字标签,每张数值卡有同色通道徽标与通道名。
---
## 3. 对设计稿的四处有意偏离
### 3.1 品牌藏青用 logo 实际取色 `#12315D`
`logo.png` 与 `logo_topbar.png` 采样后字形的实际颜色都是 `#12315D`
(H215 S80.6% V36.5%),而设计稿写的是 `#003893`(H217 但 S100% V57.6%)。
两者同色相家族,但后者明显更亮更艳。顶栏品牌条与浅色主题的箭头画布采用 logo
实际色,以保证"标识色"与"界面色"一致。
### 3.2 色带第 4 档由 `#FE5853` 提亮为 `#FF8A6B`
见 §2.1:保留设计稿珊瑚色会在色带中段留下 1.06:1 的亮度平台。提亮后色带相对亮度
为 **0.031 → 0.128 → 0.268 → 0.405 → 0.861**,严格单调。
第 3 档洋红与第 5 档柠檬绿**保留设计稿原值不动**。
### 3.3 画布 `#D5D5D5` → `#E1E6EF`
`#D5D5D5` 是无彩色(H=0 S=0% L=84%)。现代浅色画布普遍在 96–98% 亮度,目的是让
白卡"浮起来";而在 `#D5D5D5` 上白卡只有 1.47:1,且 Qt 的 QSS 不支持 `box-shadow`,
卡片只能靠 1px 描边分离,观感会发平、发旧。改为品牌微蓝灰后:
- 保留设计稿"灰底 + 白卡"的角色分工;
- `#4D62BE` 在画布上的对比度从 3.76 升到 4.41。
### 3.4 补齐语义色与深色主题
设计稿没有 success/warning/error/info,也只有浅色版;本应用默认主题是深色。
同时原深色主题的语义色是一整套 Google Material 色
(`#8ab4f8` / `#34a853` / `#fbbc04` / `#ea4335` / `#1a73e8`),对一个测量仪器
产品属于即时可识别的品牌冲突,已全部替换为品牌衍生色。
`danger` 在深色主题被拆成两个令牌:`danger`(亮,用于卡片上的红字与状态指示)
与 `danger_fill`(暗,用于承载白字的危险按钮)—— 一个色值无法同时满足两个方向。
---
## 4. 品牌化箭头色带
设计稿的霓虹三色是**一张 colormap**(按位移幅值着色),不是三个分类色。因此它落在
箭头可视化上,而不是 UI 控件上:
```
flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
```
- 第 1 档等于画布色,这样**零位移区域自然融入背景**;
- 由 `orisys_app_shared.acquisition.arrows.flow_colormap(theme)` 生成 256 档
BGR 查找表,经 `draw_arrows(colormap=...)` 注入 SDK(**SDK 无需改动**);
- LUT 缓存的键是 ramp 字符串本身,因此切换主题后立刻生效;
- 非法 ramp 永不抛异常,回退到内置默认色带。
箭头画布在两个主题里都是品牌深蓝(浅 `#12315D` / 深 `#0B1830`),这也是霓虹色带
唯一可用的背景 —— 色带第 3 档起对画布均 ≥3.0:1。**绘图区(`plot_bg`)保持浅色**,
所以浅色主题下霓虹只出现在箭头画布上,力曲线使用浅色安全的品牌三色。
---
## 5. 有记录在案的豁免
以下项目**不**被 `audit_palette()` 检查,理由是它们属于装饰而非状态指示:
| 项 | 现状 | 理由 |
|---|---|---|
| 装饰性 1px 容器描边 | `card_border` / `panel_border` / `plot_grid` / `separator`(1.16–1.45:1) | WCAG 1.4.11 针对交互控件的边界与状态指示;容器轮廓属于装饰 |
| 禁用态文字 | 深色 `text_muted` on `button_bg` = 4.07 | WCAG 1.4.3 明确豁免禁用控件 |
输入框的静止描边**不在豁免之列** —— 它被强制要求对 `input_bg`/`panel_bg`/`card_bg`/`toolbar_bg`
四种底均 ≥3.0:1(实测 3.08–4.09),这与 Material 3 描边式输入框的做法一致。焦点态同样由
`focus_ring` 保证 ≥3.0:1。
深色主题的 `accent` 对 `panel_bg` 为 3.09:1(设计稿原值 `#4D62BE`),贴线但达标;
若后续视觉上偏软,可单点提升为 `#5468C6`(白字仍为 5.03)。
---
## 6. 顶栏:一件仪器只有一条读数线
顶部由两条细轨组成,合计在 1440×880 下不超过 78 px(`ui_check.py` 的 `top area` 段与
`tests/test_ui_smoke.py::test_top_area_stays_compact` 都断言这一点):
| 轨 | 高度 | 承载 |
|---|---|---|
| 品牌轨(`QFrame#brandBar`) | 28 px | logo + 字标 + 三个顶层视图(实时监测 / 历史回放 / 标定)+ 右侧产品名 |
| 状态轨(`ToolbarWidget` 的 rail) | ~30 px | **传感器摘要按钮**、阶段、**当前视图的来源选择**(摄像机编号 + 搜索 / 使用摄像机)、标定状态、数据有效性、记录状态、记录/运行/重置/设置 |
| 设置区(默认收起) | 0 / ~40 px | 单位、记录模式、预览 |
| ~~回放条~~(已取消) | 0 px | 回放视图与实时同布局:运输控制就是状态轨的主按钮(实时=运行,回放=回放/暂停) |
四点规则:
1. **传感器区是一个摘要按钮,不是两个下拉**。轨上只有 `BOX · box/sensor.json`;类型、配置
与「当前生效的标定 / 最近导出的系数」都在它打开的 `SensorDialog` 里(见
`docs/shell-lifecycle.md` §1.2)。摘要按钮属于状态轨,因此文本必须完整显示(见第 5 条)。
2. **来源选择随视图**:实时视图显示摄像机编号 + 搜索;回放视图显示「选择录制视频…」,视频名
写在来源摘要里(只在回放视图)。没有「来源」下拉,因此没有"先选来源才能用"的状态。
3. **导航按钮在品牌底上取品牌前景色**。`navButton` 用 `brand_bar_fg_muted`,当前视图用
`brand_bar_fg` + `navActive="true"`;两者都在 §2 的品牌底对比度审计里(7.67 / 12.94),
不新增色值。
4. **状态轨上的语义色一律算在 `toolbar_bg` 上审计**。记录状态(`status_idle` /
`status_running` / `status_connecting` / `status_error`)、丢帧提示(`warning`)与数据
有效性(`success` / `warning`)此前只检查 `panel_bg`,而它们实际画在顶栏底色上。
5. **同一时刻只有一个实心按钮**(`ToolbarWidget._refresh_emphasis`):
| 状态 | 实心 | 描边 |
|---|---|---|
| 空闲 | `运行`(`primaryButton`) | — |
| 连接中 | `取消连接`(`dangerButton`) | — |
| 采集中 | `开始记录`(`primaryButton`) | `停止`(`dangerOutline`) |
| 记录中 | `停止记录`(`dangerButton`) | `停止`(`dangerOutline`) |
`dangerOutline`:透明底 + `danger` 文字与描边,hover/pressed 才填充 `danger_fill`;
`:checked`(即"采集正在跑")仍保持描边——否则一圈红边会在会话开始后悄悄变成整块红色。
6. **状态轨上的事实不得被压缩**(`ui_check.rail_squeezed`)。中文、英文、1280 与 1440
四种组合下,轨上的按钮与下拉都必须显示完整文本;只有带 tooltip 的 `ShrinkableLabel`
允许在窗口极窄时被裁切。这也是"记录模式"下拉被挪进设置区的原因:英文的
`Start recording` + `Sensor config` 与它挤在同一行时,第一个被压缩的就是按钮文字。
7. **曲线表头只有符号,符号的颜色仍来自令牌**。全程总览 / 框选 / 返回最新三个按钮是
纯图标(`QPushButton[iconOnly="true"]`),图标由 `ui/icons.py` 用 `text_secondary`
实时绘制、切主题时按新令牌重画,因此配色审计的结论对它们同样成立,也不需要新增色值。
单位芯片 `QPushButton#unitBadge` 用 `card_bg` 底 + `card_border` 描边 + `text_secondary`
文字,hover 才换成 `text_primary` 文字与 `accent` 描边,它是"可点的状态"而不是实心主
按钮,所以不参与 §6.5 的"同一时刻只有一个实心按钮"计数(`QLabel#unitBadge` 仍是通道
徽标与传感器缩略图的样式,两者靠控件类型区分)。
8. **数值卡的小字用等宽体,且不会把面板顶宽**。选区统计(均值 · σ)画在数值卡内的 9pt
等宽小字上,与 16pt 的大号数字同一列;底部状态栏不再出现长句,因此曲线面板的宽度由
三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言
该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。
9. **两个视图共用一条状态轨**:来源槽是唯一的差别(实时=摄像机、回放=「选择录制视频…」),
因此轨上的文字宽度预算对两个视图是同一个。回放的视频名写在来源摘要里,**只在回放视图**
(`_refresh_status_rail` 按 `_view_preset` 决定),中英与 1280/1440 都由
`ui_check.rail_squeezed` 覆盖。
---
## 7. 三个顶层视图 = 同一窗口的三套布局
导航不再打开对话框,而是切换主窗口的布局(`StudioMainWindow._apply_view_preset`):
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) |
|---|---|---|---|
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `0, 360, 1080, 0`(与实时相同) |
| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
| 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
- 标定视图给标定面板 880 px:六列诊断表头与九个动作按钮不再被挤压;标定面板自身还有
纵向滚动区,窗口再矮也够得到「应用到设备」。
- 标定面板列宽改为 `ResizeToContents`:诊断列头宁可让表格横向滚动,也不截成
「FN 均」这种看不懂的缩写。
- 2D 图像区域固定为所显示图像的宽高比(`AspectImageLabel`)。横向分割器**不会**向子控件
询问 `heightForWidth`,所以宽高比由 `ArrowPanel.resizeEvent` 主动施加;box 传感器的
画面旋转 90° 显示,因此其显示宽高比是 9:16 而不是 16:9。
- `视图 → 标定面板` 与「标定」导航是同一个开关:勾上进入标定视图,取消回到实时视图,
不会留下"面板已隐藏但导航还亮着"的矛盾状态。
---
## 8. 复核方式
```powershell
$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
cd D:\orisys_ws\orisys-app\apps\studio
& $py tools/ui_check.py # 含 palette 规则段落
& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --out outputs/_snap_dark.png
& $py tools/ui_snapshot.py --theme light --lang en_US --sensor finger --panels cal,3d --size 1600x900 --demo --out outputs/_snap_light.png
& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --scene recording --out outputs/_snap_recording.png
& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --preset calibration --out outputs/_snap_calibration.png
```
视图切换与关闭流程另见 `apps/studio/docs/shell-lifecycle.md`。
@@ -3,9 +3,9 @@ requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-customer"
name = "orisys-studio"
version = "0.1.0"
description = "Orisys customer desktop app"
description = "Orisys Studio desktop app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
@@ -17,7 +17,7 @@ dependencies = [
]
[project.scripts]
orisys-customer = "orisys_customer.main:main"
orisys-studio = "orisys_studio.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Launch the Orisys customer app shell."""
"""Launch the Orisys Studio app shell."""
from __future__ import annotations
@@ -18,7 +18,7 @@ import os
os.chdir(ROOT)
from orisys_customer.main import main
from orisys_studio.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,4 +1,4 @@
"""Orisys customer desktop application."""
"""Orisys Studio desktop application."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
"""Calibration service: the panel's operations, without the panel.
The panel shows a table and emits status messages; everything that touches the
file system or a device identity happens here:
* preparing the session once (store, diagnostics and export share one model);
* saving the audit record, reopening one, and exporting the coefficients;
* keeping a reopened record's **own** device identity instead of restamping it
with whatever device happens to be connected.
This is deliberately Qt-free so it can be tested without a display.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS,
CalSessionStore,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_session import (
CalibrationSessionRecord,
read_calibration_session,
session_filename,
write_calibration_session,
)
from orisys_app_shared.paths import calibration_export_dir
logger = logging.getLogger(__name__)
class CalibrationService:
"""Owns the calibration session's files and its prepared model."""
def __init__(self, *, store: Optional[CalSessionStore] = None) -> None:
self.store = store or CalSessionStore()
#: The one prepared model the panel displays and exports.
self.prepared: Any = None
#: Device identity of a reopened record, while it is open.
self.record_identity: Optional[dict] = None
#: The record currently open, if any.
self.open_record: Optional[CalibrationSessionRecord] = None
# --------------------------------------------------------------- identity
def export_meta(
self,
*,
sensor_type: str,
config_ref: str | None,
sn: str = "",
duration_s: float = 0.0,
) -> dict:
"""The identity/pipeline metadata an export would carry.
While a saved record is open its **own** device identity wins: reopening
device A's calibration on a machine connected to device B must keep saying
A, or the evidence is silently restamped with B's serial number.
"""
meta = meta_from_sensor_json(config_ref)
meta["sensor_type"] = str(sensor_type or "box")
meta["sn"] = str(sn or "")
meta["record_duration_s"] = float(duration_s or 0.0)
identity = self.record_identity
if identity:
meta["sensor_type"] = identity.get("sensor_type") or meta["sensor_type"]
meta["sn"] = identity.get("sn") or meta["sn"]
meta["config_ref"] = identity.get("config_ref") or meta.get("config_ref")
meta["source_record"] = str(identity.get("path") or "")
meta["source_created_at"] = str(identity.get("created_at") or "")
return meta
def clear_record_identity(self) -> None:
"""A new run is the live device's context again."""
self.record_identity = None
self.open_record = None
# ------------------------------------------------------------ preparation
def prepare(self, recorder, meta: dict) -> Any:
"""Prepare the session once: store, diagnostics and export share it."""
self.prepared = None
self.store.clear()
if recorder is None:
return None
prepared = recorder.prepared(meta=meta)
self.prepared = prepared
for key in CHANNEL_KEYS:
self.store.set_channel(key, prepared.channel_samples(key))
# The residuals belong to the points, because the table shows them.
for point in recorder.all_points():
value = prepared.residuals.get(id(point))
point.residual_n = float("nan") if value is None else float(value)
return prepared
# ------------------------------------------------------------------- files
def save_record(self, recorder, meta: dict) -> Path:
"""Write the audit record. Returns the written path."""
points = recorder.all_points()
path = calibration_export_dir() / session_filename(meta.get("sensor_type"))
return write_calibration_session(path, points, meta=meta)
def load_record(self, path: str | Path) -> CalibrationSessionRecord:
"""Reopen a saved session and remember whose record it is."""
record = read_calibration_session(path)
identity = record.identity()
self.record_identity = {**identity, "path": str(record.path)}
self.open_record = record
return record
def export_coefficients(
self,
*,
sensor_type: str,
export_dir: str | Path | None = None,
local_bundle_dir: str | Path | None = None,
) -> tuple[Path, Path | None, list[str]]:
"""Serialise the prepared model. Raises ValueError when there is none."""
if self.prepared is None or not getattr(self.prepared, "ok", False):
warnings = list(getattr(self.prepared, "warnings", []) or [])
raise ValueError("; ".join(warnings) or "no complete channels")
return export_force_calibration_v2(
self.prepared,
export_dir=export_dir or calibration_export_dir(),
sensor_type=str(sensor_type or "box"),
local_bundle_dir=local_bundle_dir,
)
@@ -1,8 +1,9 @@
"""Customer app entrypoint."""
"""Studio app entrypoint."""
from __future__ import annotations
import logging
import os
import sys
from PyQt5.QtWidgets import QApplication
@@ -15,14 +16,14 @@ from orisys_app_shared.paths import prefer_local_sensor_bundles
from orisys_app_shared.prefs import load_preferences
from orisys_app_shared.theme import build_qss
from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version
from orisys_customer.app_window import CustomerMainWindow
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
from orisys_studio.app_window import StudioMainWindow
logger = logging.getLogger(__name__)
def main() -> int:
init_logging("orisys-customer")
init_logging("orisys-studio")
install_exception_hooks()
preferred = prefer_local_sensor_bundles()
if preferred is not None:
@@ -42,14 +43,31 @@ def main() -> int:
set_locale(language)
app = QApplication(sys.argv)
app.setApplicationName("Orisys Customer")
app.setApplicationName("Orisys Studio")
app.setApplicationVersion(app_version)
app.setStyleSheet(build_qss(theme))
window = CustomerMainWindow(prefs=prefs)
window = StudioMainWindow(prefs=prefs)
window.show()
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
return int(app.exec_())
logger.info("Studio shell started (theme=%s, language=%s)", theme, language)
code = int(app.exec_())
# Leave the process immediately.
#
# The device SDK and OpenCV keep *native* threads and handles of their own;
# a normal return would run interpreter shutdown on top of them, and the
# process then sat there after the window closed ("the console never came
# back"). Everything that must be durable was already flushed by
# ``StudioMainWindow.closeEvent`` and by the log handlers below, so the only
# thing skipped here is teardown of libraries that no longer have anything
# to write.
logging.shutdown()
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except Exception: # noqa: BLE001 - a closed stream must not block exit
pass
os._exit(code)
if __name__ == "__main__":
+267
View File
@@ -0,0 +1,267 @@
"""The record channel: one experiment directory, one session store, one sampler.
This is the coordination the main window used to do inline — and it is deliberately
free of Qt. Three consumers are fed from the same acquisition packet:
* the :class:`ExperimentRecorder`, which writes the directory;
* the whole-session store the charts and the browser read;
* the calibration sampler (``CalibrationRecorder``), whose lifecycle must not
depend on the calibration panel being visible.
The window keeps the buttons, the menus and the status messages; the decisions
about *what* is recorded, *how* it is named and *what actually happened* live
here, where they can be tested without a display.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Callable, Optional
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
from orisys_app_shared.record.sample import to_record_sample
logger = logging.getLogger(__name__)
class RecordingCoordinator:
"""Owns the recorder and the per-packet fan-out."""
def __init__(
self,
*,
session_store,
cal_recorder,
recorder: Optional[ExperimentRecorder] = None,
) -> None:
self.session_store = session_store
self.cal_recorder = cal_recorder
self.recorder = recorder or ExperimentRecorder()
#: Confirmed calibration changes: ``(path, version, frame_id, mono_t, session)``.
self._calibration_events: list[tuple[str, str, int, float, int]] = []
#: Confirmed zeroings: ``(frame_id, mono_t, session)``.
self._reset_events: list[tuple[int, float, int]] = []
#: Raised by the writer thread; drained on the UI thread.
self._alarms: list[str] = []
#: A stop was requested and the writer has not closed its files yet.
self.report_pending = False
self.last_report: Optional[RecordReport] = None
self._active_calibration_id = ""
self._record_fps = 30.0
self.recorder.set_alarm_handler(self._on_alarm)
# ------------------------------------------------------------- acquisition
def on_packet(self, packet):
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
The packet is converted exactly once, by the same function the CSV writer
uses, so the store and the file can never disagree about whether a value
is a measurement. Returns the converted record, which the caller may use
for its own (UI-side) bookkeeping.
"""
record = to_record_sample(
packet, calibration_version=self._active_calibration_id
)
from orisys_app_shared.force.session import SessionSample
sample = SessionSample.from_record(record)
self.session_store.append(sample)
self.cal_recorder.feed(sample)
# Video + CSV + metadata share this one record path.
self.recorder.on_packet(packet)
fps = float(getattr(packet, "fps", 0.0) or 0.0)
if fps > 0.0:
self._record_fps = fps
return record
def take_preroll(self, worker) -> list:
"""Frame history from the active worker, if it keeps one."""
take = getattr(worker, "take_preroll", None)
if not callable(take):
return []
try:
return list(take())
except Exception: # noqa: BLE001 - the history is optional, not the record
logger.exception("Could not take the pre-roll buffer")
return []
def set_active_calibration(self, version: str) -> None:
"""The calibration the record should name until the device says otherwise."""
self._active_calibration_id = str(version or "")
@property
def active_calibration(self) -> str:
return self._active_calibration_id
@property
def record_fps(self) -> float:
return self._record_fps
# ------------------------------------------------------------------ events
def note_calibration_applied(
self, path: str, version: str, frame_id: int, mono_t: float, session: int
) -> None:
"""Called from the acquisition thread once coefficients are loaded."""
self._calibration_events.append(
(str(path), str(version), int(frame_id), float(mono_t), int(session))
)
def note_reset(self, frame_id: int, mono_t: float, session: int) -> None:
"""Called from the acquisition thread once a zeroing took effect."""
self._reset_events.append((int(frame_id), float(mono_t), int(session)))
def drain_calibration_events(self) -> list[dict]:
"""Publish confirmed calibrations. Returns what the UI should show."""
published: list[dict] = []
while self._calibration_events:
path, version, frame_id, mono_t, session = self._calibration_events.pop(0)
# The version is the calibration's content hash: a path can be
# re-exported with different coefficients, a hash cannot.
self._active_calibration_id = version
self.session_store.set_calibration_id(version)
self.recorder.set_calibration_version(version)
snapshot = self.snapshot_calibration(path, version, mono_t, frame_id)
self.recorder.log_event(
"calibration_applied",
f"{path} ({version[:12] or 'unhashed'})"
+ (f" -> {snapshot}" if snapshot else ""),
mono_t=mono_t,
frame_id=frame_id,
)
published.append(
{
"path": path,
"version": version,
"frame_id": frame_id,
"mono_t": mono_t,
"session": session,
"snapshot": snapshot,
}
)
return published
def drain_reset_events(self) -> list[dict]:
"""Publish confirmed zeroings."""
published: list[dict] = []
while self._reset_events:
frame_id, mono_t, session = self._reset_events.pop(0)
self.recorder.log_event(
"zeroing",
"tracker reset (applied by the acquisition thread)",
mono_t=mono_t,
frame_id=frame_id,
)
published.append(
{"frame_id": frame_id, "mono_t": mono_t, "session": session}
)
return published
def snapshot_calibration(
self, path: str, version: str, mono_t: float, frame_id: int
) -> str:
"""Copy the applied coefficients into the recording. Returns its name."""
directory = self.recorder.directory
if directory is None or not path or not Path(path).is_file():
return ""
try:
name = store_calibration_snapshot(directory, Path(path), version)
except Exception: # noqa: BLE001 - the record continues without it
logger.exception("Could not snapshot the applied calibration")
return ""
self.recorder.note_calibration_snapshot(
version=version, name=name, frame_id=frame_id, mono_t=mono_t
)
return name
# ------------------------------------------------------------------ alarms
def _on_alarm(self, message: str) -> None:
"""Called from the writer thread. Queue it; never touch Qt here."""
logger.error("Recording alarm: %s", message)
self._alarms.append(str(message))
def drain_alarms(self) -> list[str]:
drained, self._alarms = self._alarms, []
return drained
# --------------------------------------------------------------- lifecycle
def start(
self,
*,
root: Path,
config: RecordConfig,
metadata: dict[str, Any],
calibration_id: str = "",
calibration_version: str = "",
preroll=(),
) -> Path:
"""Start a recording. Raises when the directory cannot be created."""
directory = self.recorder.start(
root=root,
config=config,
session_id=int(self.session_store.session_id),
metadata=metadata,
calibration_id=calibration_id,
calibration_version=calibration_version,
preroll=preroll,
)
self.last_report = None
self.report_pending = False
return Path(directory)
def stop(self, *, wait: bool = True) -> Optional[RecordReport]:
"""Stop and return the report, or ``None`` when nothing was started."""
if self.recorder.directory is None and self.recorder.report is None:
self.report_pending = False
return None
if self.recorder.is_recording:
self.recorder.log_event("record_stopped", "")
report = self.recorder.stop(wait=wait)
self.publish(report)
return report
def publish(self, report: Optional[RecordReport]) -> None:
"""Record a report; a completed one becomes ``last_report``."""
if report is None:
return
if not report.completed:
# The writer is still closing; the UI timer publishes the real one.
self.report_pending = True
return
self.report_pending = False
self.last_report = report
def poll(self) -> Optional[RecordReport]:
"""The terminal report, once the writer has actually closed the files."""
if not self.report_pending:
return None
report = self.recorder.report
if report is None:
return None
self.publish(report)
return report
@property
def is_recording(self) -> bool:
return self.recorder.is_recording
@property
def directory(self):
return self.recorder.directory
def record_config_from_prefs(
prefs: dict,
*,
mode: str,
fps: float,
on_problem: Optional[Callable[[str], None]] = None,
) -> RecordConfig:
"""Build the record config the toolbar/prefs describe."""
return RecordConfig(
video=(mode == "video_data"),
lossless_frames=bool(prefs.get("record_lossless_frames", False)),
fps=float(fps),
segment_seconds=float(prefs.get("record_segment_seconds", 300.0)),
)
+216
View File
@@ -0,0 +1,216 @@
"""Replay engine: one recording, one cursor, one frame at a time.
This is the headless half of the replay view. It owns the recording reader and
the cursor and it *shows* nothing: the main window renders the frame into the 2D
panel, the curves read the recording through
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport
on the chart header drives :meth:`set_position`.
There is no depth recomputation here: depth is not stored with a recording, the
replay view shows the recorded frames, and re-running the pipeline over a video
is the acquisition path's job (see the studio's "recompute" mode).
"""
from __future__ import annotations
import logging
import numpy as np
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtGui import QImage
from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader
logger = logging.getLogger(__name__)
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
rgb = frame[:, :, ::-1].copy()
height, width, channels = rgb.shape
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
def _sdk_version() -> str:
"""The SDK version in use, or "" when the SDK is not importable."""
try:
import orisys
return str(getattr(orisys, "__version__", "") or "")
except Exception: # noqa: BLE001 - a missing SDK is a warning, not a crash
logger.debug("Could not read the SDK version", exc_info=True)
return ""
class ReplayController(QObject):
"""Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the chart cursor and the 2D panel follow.
position_changed = pyqtSignal(float)
#: The frame under the cursor changed.
frames_changed = pyqtSignal()
#: The notes about this recording, or the loaded recording, changed.
status_changed = pyqtSignal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._reader: RecordingReader | None = None
self._unit = "raw"
self._position = 0.0
#: The frame cache: one video image per cursor position.
self._video_position: float | None = None
self._video_qimage: QImage | None = None
self._video_reason = ""
self._warnings = ""
# ------------------------------------------------------------- recording
def is_loaded(self) -> bool:
return self._reader is not None
def directory(self):
return self._reader.directory if self._reader is not None else None
def reader(self):
return self._reader
def info(self):
return self._reader.info() if self._reader is not None else None
def source(self):
"""The curve source for the charts, or None when nothing is open."""
if self._reader is None:
return None
from orisys_app_shared.force.source import RecordingCurveSource
return RecordingCurveSource(self._reader)
def open_recording(self, directory) -> bool:
"""Open a recording directory."""
self._release_reader()
try:
self._reader = RecordingReader(directory)
self._reader.info()
except Exception: # noqa: BLE001 - reported through the status text
logger.exception("Could not open recording %s", directory)
self._reader = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
return False
self._reset_frames()
self._position = 0.0
self.recording_changed.emit(True)
self._refresh_warnings()
self.set_position(0.0)
return True
def close(self) -> None:
"""Drop the recording (the decoder is released with it)."""
self._release_reader()
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
def _release_reader(self) -> None:
reader, self._reader = self._reader, None
if reader is not None:
try:
reader.close()
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("Could not close the recording reader", exc_info=True)
def _reset_frames(self) -> None:
self._video_position = None
self._video_qimage = None
self._video_reason = ""
# ---------------------------------------------------------------- cursor
def position(self) -> float:
return self._position
def duration_s(self) -> float:
if self._reader is None:
return 0.0
return float(self._reader.info().duration_s)
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: the video frame and the charts follow it."""
if self._reader is None:
return
duration = self.duration_s()
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self._refresh_video()
self.position_changed.emit(self._position)
self.frames_changed.emit()
def unit(self) -> str:
return self._unit
def set_unit(self, unit: str) -> str:
"""Show the recording in ``unit``; the charts and the rail share one.
Opening a recording that carries no calibrated axis in newton mode would
draw three empty charts, so the caller picks the unit from what the
recording actually has and tells every view about it here.
"""
self._unit = "N" if str(unit).upper().startswith("N") else "raw"
return self._unit
# ----------------------------------------------------------------- frames
def video_image(self) -> QImage | None:
self._refresh_video()
return self._video_qimage
def video_message(self) -> str:
self._refresh_video()
return self._video_reason
def _refresh_video(self) -> None:
if self._reader is None:
self._video_position = None
self._video_qimage = None
self._video_reason = t("playback.none_found")
return
if self._video_position == self._position:
return
self._video_position = self._position
image, frame = self._reader.video_frame_at(self._position)
if image is None:
self._video_qimage = None
self._video_reason = (
t("playback.frame_dropped")
if frame is not None and not frame.in_video
else t("playback.frame_unreadable")
)
return
self._video_reason = ""
self._video_qimage = _bgr_to_qimage(image)
# ---------------------------------------------------------------- notes
def warnings_text(self) -> str:
return self._warnings
def _refresh_warnings(self) -> None:
self._warnings = ""
if self._reader is None:
return
info = self._reader.info()
codes = self._reader.replay_warnings(sdk_version=_sdk_version())
text = ""
if codes:
text = t(
"playback.warnings",
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
)
if info.decimated:
# The curves are a view, not every sample: say so rather than let the
# operator count points.
note = t(
"playback.decimated",
shown=len(info.times),
total=info.total_rows,
)
text = f"{text} {note}" if text else note
self._warnings = text
@@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayo
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_customer import __version__ as app_version
from orisys_studio import __version__ as app_version
class AboutDialog(QDialog):
@@ -0,0 +1,426 @@
"""2D image panel: arrows / depth view with mode selector."""
from __future__ import annotations
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from orisys_studio.ui.fit_text import ShrinkableLabel
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
#: While replaying a recording there is no optical flow to draw and no depth is
#: stored: the panel shows the recorded frames, and nothing else.
REPLAY_VIEW_MODES = ("recorded",)
_VIEW_LABELS = {
"arrows_pure": "panels.view_arrows_pure",
"arrows_stitched": "panels.view_arrows_stitched",
"depth": "panels.view_depth",
"recorded": "panels.view_recorded",
}
_ROTATION_STEPS = (0, 90, 180, 270)
_COLORBAR_WIDTH = 120
_COLORBAR_HEIGHT = 10
#: Sensor output is 1920x1080; used until the first frame states its own shape.
DEFAULT_FRAME_ASPECT = 16.0 / 9.0
_MIN_ASPECT = 0.2
_MAX_ASPECT = 5.0
#: Header + footer + margins, i.e. what the image must leave room for.
_IMAGE_CHROME_HEIGHT = 96
#: Smallest usable image area. Kept as a constant because ``setFixedHeight``
#: overwrites ``minimumHeight``, so the floor cannot be read back from the label.
_IMAGE_MIN_HEIGHT = 120
def _normalize_rotation_deg(value: object, default: int = 90) -> int:
try:
deg = int(value) % 360 # type: ignore[arg-type]
except (TypeError, ValueError):
return default
if deg not in _ROTATION_STEPS:
deg = int(round(deg / 90.0) * 90) % 360
return deg
class AspectImageLabel(QLabel):
"""Image area whose aspect ratio follows the frame it displays.
A free-growing label turned a 16:9 frame into a tall letterbox with wide
empty bands. Here the height is derived from the width and the frame's own
aspect, so the 2D view always has the shape of the picture it shows.
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._aspect = DEFAULT_FRAME_ASPECT
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
policy.setHeightForWidth(True)
self.setSizePolicy(policy)
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
def aspect(self) -> float:
return self._aspect
def set_aspect(self, aspect: float) -> None:
try:
value = float(aspect)
except (TypeError, ValueError):
return
if not (_MIN_ASPECT <= value <= _MAX_ASPECT):
return
if abs(value - self._aspect) < 1e-6:
return
self._aspect = value
self.updateGeometry()
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt naming
return True
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt naming
return max(1, int(round(int(width) / self._aspect)))
class ColorBarLabel(QLabel):
"""The depth view's colour strip, painted from the theme's flow ramp.
It is generated from the *same* lookup table ``depth_map_to_bgr`` uses, so
the legend can never disagree with the picture it explains.
"""
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self._theme = str(theme)
self.setObjectName("colorBar")
self.setFixedHeight(_COLORBAR_HEIGHT)
self.setMinimumWidth(_COLORBAR_WIDTH)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setVisible(False)
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self.update()
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
from orisys_app_shared.acquisition.arrows import flow_colormap
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
# the whole call, which is what QImage's borrowed buffer needs.
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
painter.end()
class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = str(theme)
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.title = QLabel()
self.title.setObjectName("panelTitle")
self._rotation_deg = 90
self._rotation_enabled = True
self._source_image: QImage | None = None
self._depth_scale_text = ""
self._arrow_scale = 2.0
self._valid_area = False
self._source_kind = "live"
self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
header.addWidget(self.title)
header.addStretch(1)
header.addWidget(self.view_combo)
layout.addLayout(header)
self.image_label = AspectImageLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setScaledContents(False)
self.image_label.setWordWrap(True)
# The image keeps the frame's shape and is centred in whatever height is
# left, instead of stretching to fill a panel with a different aspect.
layout.addStretch(1)
layout.addWidget(self.image_label)
layout.addStretch(1)
layout.addLayout(self._build_footer())
self._placeholder = ""
self._populate_view_combo()
self.retranslate()
self._refresh_footer_visibility()
self._sync_aspect()
def _build_footer(self) -> QHBoxLayout:
"""What the current view means, in one line under the canvas."""
footer = QHBoxLayout()
footer.setSpacing(10)
def annotation() -> QLabel:
label = ShrinkableLabel()
label.setObjectName("hintText")
return label
self.scale_label = annotation()
self.pipeline_label = annotation()
self.area_label = annotation()
# Shows the fixed depth colour-scale range, so two frames can be
# compared instead of the scale silently re-fitting every frame.
self.depth_scale_label = QLabel()
self.depth_scale_label.setObjectName("panelMeta")
self.depth_scale_label.setVisible(False)
self.colorbar = ColorBarLabel(theme=self._theme)
footer.addWidget(self.scale_label)
footer.addWidget(self.pipeline_label)
footer.addWidget(self.area_label)
footer.addStretch(1)
footer.addWidget(self.colorbar)
footer.addWidget(self.depth_scale_label)
return footer
def view_mode(self) -> str:
data = self.view_combo.currentData()
return str(data or "arrows_pure")
def set_view_mode(self, mode: str) -> None:
wanted = str(mode or "arrows_pure")
if wanted not in VIEW_MODES:
wanted = "arrows_pure"
idx = self.view_combo.findData(wanted)
if idx < 0:
idx = 0
self.view_combo.blockSignals(True)
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False)
self._refresh_footer_visibility()
def rotation_deg(self) -> int:
return self._rotation_deg
def set_rotation_deg(self, deg: object) -> None:
self._rotation_deg = _normalize_rotation_deg(deg)
self._sync_aspect()
self._repaint_source()
def set_rotation_enabled(self, enabled: bool) -> None:
wanted = bool(enabled)
if wanted == self._rotation_enabled:
return
self._rotation_enabled = wanted
self._sync_aspect()
self._repaint_source()
def _sync_aspect(self) -> None:
"""Re-derive the image area's shape from the frame (or from the default).
Rotating the picture swaps which side is long, so the area has to follow
the rotation as well as the frame.
"""
image = self._source_image
if image is not None and not image.isNull():
self._set_display_aspect(image.width(), image.height())
return
self.image_label.set_aspect(DEFAULT_FRAME_ASPECT)
if self._effective_rotation_deg() % 180:
self.image_label.set_aspect(1.0 / DEFAULT_FRAME_ASPECT)
self._sync_image_height()
def _set_display_aspect(self, frame_w: int, frame_h: int) -> None:
if frame_w <= 0 or frame_h <= 0:
return
aspect = frame_w / frame_h
if self._effective_rotation_deg() % 180:
aspect = 1.0 / aspect
self.image_label.set_aspect(aspect)
self._sync_image_height()
def rotate_90_ccw(self) -> int:
if not self._rotation_enabled:
return self._rotation_deg
self.set_rotation_deg(self._rotation_deg + 90)
return self._rotation_deg
def set_arrow_scale(self, factor: float) -> None:
"""Show how much the drawn arrows are magnified relative to displacement."""
try:
self._arrow_scale = float(factor)
except (TypeError, ValueError):
self._arrow_scale = 1.0
self._refresh_scale_label()
def set_valid_area(self, present: bool) -> None:
"""Say whether the flow grid is masked to the configured valid area."""
self._valid_area = bool(present)
self.area_label.setText(
t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full")
)
def set_source_kind(self, kind: str) -> None:
"""Switch between live frames and a recording being replayed.
A recording has no optical flow to draw, so the arrow entries disappear
rather than offering a view that would be a lie.
"""
wanted = "replay" if str(kind) == "replay" else "live"
if wanted == self._source_kind:
return
self._source_kind = wanted
self._populate_view_combo()
self._refresh_pipeline_label()
self._refresh_footer_visibility()
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder)
def source_kind(self) -> str:
return self._source_kind
def view_modes(self) -> tuple[str, ...]:
return REPLAY_VIEW_MODES if self._source_kind == "replay" else VIEW_MODES
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self.colorbar.set_theme(self._theme)
def set_depth_scale(self, text: str | None) -> None:
"""Show the fixed depth colour-scale range (``None`` hides it)."""
self._depth_scale_text = str(text or "")
self.depth_scale_label.setText(self._depth_scale_text)
self.depth_scale_label.setToolTip(t("panels.depth_scale_tip"))
self._refresh_footer_visibility()
def _refresh_scale_label(self) -> None:
self.scale_label.setText(
t("panels.arrow_scale", factor=f"{self._arrow_scale:g}")
)
self.scale_label.setToolTip(t("panels.arrow_scale_tip"))
def _refresh_footer_visibility(self) -> None:
"""Arrow annotations and the depth legend are mutually exclusive."""
depth = self.view_mode() == "depth"
replay = self._source_kind == "replay"
# The magnification and the valid area describe the live flow drawing;
# neither exists for a recorded frame.
self.scale_label.setVisible(not depth and not replay)
self.pipeline_label.setVisible(not depth)
self.area_label.setVisible(not depth and not replay)
self.colorbar.setVisible(depth)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
def _populate_view_combo(self) -> None:
current = self.view_combo.currentData()
self.view_combo.blockSignals(True)
self.view_combo.clear()
for mode in self.view_modes():
self.view_combo.addItem(t(_VIEW_LABELS[mode]), mode)
if current:
idx = self.view_combo.findData(current)
if idx >= 0:
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False)
def _on_view_index_changed(self, _index: int) -> None:
self._refresh_footer_visibility()
self.view_mode_changed.emit(self.view_mode())
def retranslate(self) -> None:
self.title.setText(t("panels.arrow"))
for i, mode in enumerate(self.view_modes()):
if i < self.view_combo.count():
self.view_combo.setItemText(i, t(_VIEW_LABELS[mode]))
self.view_combo.setToolTip(t("panels.view_mode_tip"))
self.image_label.setToolTip(t("panels.arrow_empty_tip"))
self._refresh_pipeline_label()
self._refresh_scale_label()
self.set_valid_area(self._valid_area)
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder)
def _refresh_pipeline_label(self) -> None:
if self._source_kind == "replay":
self.pipeline_label.setText(t("panels.replay_pipeline"))
self.pipeline_label.setToolTip(t("panels.replay_pipeline_tip"))
return
self.pipeline_label.setText(t("panels.arrow_pipeline"))
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
def _empty_message(self) -> str:
if self._source_kind == "replay":
return t("panels.replay_empty")
return t("panels.arrow_empty")
def _effective_rotation_deg(self) -> int:
if not self._rotation_enabled:
return 0
return self._rotation_deg
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
super().resizeEvent(event)
self._sync_image_height()
def _sync_image_height(self) -> None:
"""Give the image area the height its own aspect ratio asks for.
A horizontal splitter hands its children the full available height and
never asks them for ``heightForWidth``, so the aspect ratio has to be
applied here or the 16:9 frame stretches into a tall letterbox.
"""
label = self.image_label
wanted = label.heightForWidth(max(1, label.width()))
room = max(_IMAGE_MIN_HEIGHT, self.height() - _IMAGE_CHROME_HEIGHT)
height = max(_IMAGE_MIN_HEIGHT, min(wanted, room))
if label.maximumHeight() != height:
label.setFixedHeight(height)
def _repaint_source(self) -> None:
if self._source_image is None or self._source_image.isNull():
return
self._paint_image(self._source_image)
def _paint_image(self, image: QImage) -> None:
pix = QPixmap.fromImage(image)
# The area takes the shape of the *displayed* picture.
self._set_display_aspect(image.width(), image.height())
deg = self._effective_rotation_deg()
if deg:
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
def show_frame(self, image: QImage) -> None:
if image.isNull():
return
self._source_image = image
self._paint_image(image)
def clear_preview(self, message: str | None = None) -> None:
self._source_image = None
self.image_label.clear()
self.image_label.setPixmap(QPixmap())
text = message if message is not None else t("panels.arrow_empty")
self._placeholder = text
self.image_label.setText(text)
@@ -0,0 +1,175 @@
"""Brand bar shown at the top of the studio shell.
A slim deep-navy rail carrying the corporate logo lockup, the product name and
the three top-level views (live / replay / calibration). Logo colours are
re-tinted at runtime so a single asset works on the brand background in both
themes; all other colours come from the theme tokens.
The rail is deliberately thin (``BAR_HEIGHT``): the instrument's own state lives
in the status rail below it, not here.
"""
from __future__ import annotations
import logging
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QPainter, QPixmap
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_app_shared.theme import color
logger = logging.getLogger(__name__)
LOGO_SIZE = 16
BAR_HEIGHT = 28
DIVIDER_HEIGHT = 14
def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap:
"""Return ``path`` recoloured to ``tint``, preserving its alpha channel.
Returns a null pixmap when the source cannot be loaded, so callers can fall
back to a text-only lockup.
"""
source = QPixmap(str(path))
if source.isNull():
return QPixmap()
scaled = source.scaled(
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
)
out = QPixmap(scaled.size())
out.fill(Qt.transparent)
painter = QPainter(out)
painter.drawPixmap(0, 0, scaled)
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
painter.fillRect(out.rect(), QColor(tint))
painter.end()
return out
class BrandBar(QFrame):
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
#: The user asked for the live view.
live_requested = pyqtSignal()
#: The user asked for the history/replay view.
playback_requested = pyqtSignal()
#: The user asked for the calibration view.
calibration_requested = pyqtSignal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self._theme = theme
self.setObjectName("brandBar")
self.setFixedHeight(BAR_HEIGHT)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 0, 10, 0)
layout.setSpacing(8)
self.logo = QLabel()
self.logo.setObjectName("brandLogo")
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
self.logo.setAlignment(Qt.AlignCenter)
layout.addWidget(self.logo)
# The lockup keeps its two labels (the product's tagline stays available
# as a tooltip), but the rail is a single line now.
lockup = QVBoxLayout()
lockup.setContentsMargins(0, 0, 0, 0)
lockup.setSpacing(0)
self.name = QLabel()
self.name.setObjectName("brandName")
self.tagline = QLabel()
self.tagline.setObjectName("brandTagline")
self.tagline.setVisible(False)
lockup.addWidget(self.name)
lockup.addWidget(self.tagline)
layout.addLayout(lockup)
self.btn_live = self._nav_button()
self.btn_playback = self._nav_button()
self.btn_calibration = self._nav_button()
self.btn_live.clicked.connect(lambda _checked=False: self.live_requested.emit())
self.btn_playback.clicked.connect(lambda _checked=False: self.playback_requested.emit())
self.btn_calibration.clicked.connect(
lambda _checked=False: self.calibration_requested.emit()
)
layout.addSpacing(4)
layout.addWidget(self.btn_live)
layout.addWidget(self.btn_playback)
layout.addWidget(self.btn_calibration)
layout.addStretch(1)
self.divider = QFrame()
self.divider.setObjectName("sectionSeparator")
self.divider.setFixedSize(1, DIVIDER_HEIGHT)
layout.addWidget(self.divider)
self.product = QLabel()
self.product.setObjectName("brandProduct")
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
layout.addWidget(self.product)
self.set_theme(theme)
self.retranslate()
self.set_active_view("live")
def _nav_button(self) -> QPushButton:
button = QPushButton()
button.setObjectName("navButton")
button.setCursor(Qt.PointingHandCursor)
button.setFlat(True)
button.setCheckable(True)
return button
def _refresh_logo(self) -> None:
pixmap = tinted_pixmap(
logo_topbar_path(), color(self._theme, "brand_bar_fg", "#FFFFFF")
)
if pixmap.isNull():
logger.debug("Brand logo unavailable; showing text-only lockup")
self.logo.clear()
self.logo.hide()
return
self.logo.setPixmap(pixmap)
self.logo.show()
def set_active_view(self, view: str | None) -> None:
"""Mark which top-level view is on screen.
``None`` leaves all three unmarked: a hand-arranged layout is not one of
the three views, and pretending otherwise would misreport the state.
"""
active = str(view or "")
for button, name in (
(self.btn_live, "live"),
(self.btn_playback, "replay"),
(self.btn_calibration, "calibration"),
):
is_active = name == active
button.setChecked(is_active)
button.setProperty("navActive", "true" if is_active else "false")
button.style().unpolish(button)
button.style().polish(button)
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self._refresh_logo()
def retranslate(self) -> None:
self.name.setText(t("brand.name"))
self.tagline.setText(t("brand.tagline"))
self.product.setText(t("app.title"))
self.name.setToolTip(f"{t('app.title')} — {t('brand.tagline')}")
self.btn_live.setText(t("brand.nav_live"))
self.btn_live.setToolTip(t("brand.nav_live_tip"))
self.btn_playback.setText(t("brand.nav_playback"))
self.btn_playback.setToolTip(t("brand.nav_playback_tip"))
self.btn_calibration.setText(t("brand.nav_calibration"))
self.btn_calibration.setToolTip(t("brand.nav_calibration_tip"))
@@ -0,0 +1,792 @@
"""Force calibration sampling panel (FN / FX / FY)."""
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase
from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
ChannelKey,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_session import (
session_filename,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir
from orisys_app_shared.theme import color
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
CHANNEL_LABELS = ("fn", "fx", "fy")
#: Diagnostic columns, in display order: reference load, the three axis means,
#: a stability measure and the point's state.
DIAGNOSTIC_COLUMNS = ("load", "fn", "fx", "fy", "stability", "state")
def _mono_font() -> str:
families = set(QFontDatabase().families())
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
if name in families:
return name
return "monospace"
class CalPanel(QFrame):
status_message = pyqtSignal(str)
#: Coefficients were written to disk. It does **not** mean they were applied.
calibration_exported = pyqtSignal(str)
#: The operator asked to push the last export into the running device.
apply_requested = pyqtSignal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
#: The files, the identity and the prepared model live in the service;
#: this panel shows them and emits status messages.
self._service = CalibrationService()
self._store = self._service.store
self._channel: ChannelKey = "fn"
self._running = False
#: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the
#: lifecycle no longer depends on this panel being visible.
self._recorder = None
#: Last successfully written coefficients file; applying is explicit.
self._last_export_path = ""
self._live_fn = 0.0
self._live_fx = 0.0
self._live_fy = 0.0
self._sensor_type = "box"
self._config_ref: str | None = None
self._mono = _mono_font()
self._mono_font = QFont(self._mono, 10)
# Everything below the title scrolls: this panel has more content than
# a short window can show (a six-column table, the fit summary, the SN
# field and nine actions), and a clipped button is a lost function.
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
content = QWidget()
self.scroll.setWidget(content)
outer.addWidget(self.scroll)
root = QVBoxLayout(content)
root.setContentsMargins(9, 9, 9, 9)
root.setSpacing(8)
title_row = QHBoxLayout()
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.channel_badge = QLabel()
self.channel_badge.setObjectName("unitBadge")
title_row.addWidget(self.title)
title_row.addStretch(1)
title_row.addWidget(self.channel_badge)
root.addLayout(title_row)
channel_row = QHBoxLayout()
channel_row.setSpacing(10)
self.channel_group = QButtonGroup(self)
self.radio_fn = QRadioButton("FN")
self.radio_fx = QRadioButton("FX")
self.radio_fy = QRadioButton("FY")
self.radio_fn.setChecked(True)
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
self.channel_group.addButton(radio, i)
channel_row.addWidget(radio)
channel_row.addStretch(1)
self.channel_group.buttonToggled.connect(self._on_channel_button)
root.addLayout(channel_row)
form = QFormLayout()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._weight_label = QLabel()
self._weight_label.setObjectName("fieldLabel")
self.weight_spin = QDoubleSpinBox()
self.weight_spin.setRange(0.0, 1e6)
self.weight_spin.setDecimals(4)
self.weight_spin.setSingleStep(0.1)
self.weight_spin.setValue(0.0)
self._duration_label = QLabel()
self._duration_label.setObjectName("fieldLabel")
self.duration_spin = QDoubleSpinBox()
self.duration_spin.setRange(5.0, 10.0)
self.duration_spin.setDecimals(1)
self.duration_spin.setSingleStep(0.5)
self.duration_spin.setValue(7.0)
self.duration_spin.setSuffix(" s")
form.addRow(self._weight_label, self.weight_spin)
form.addRow(self._duration_label, self.duration_spin)
root.addLayout(form)
rec_row = QHBoxLayout()
self.btn_record = QPushButton()
self.btn_record.setMinimumHeight(32)
self.btn_record.clicked.connect(self._on_record_clicked)
rec_row.addWidget(self.btn_record)
rec_row.addStretch(1)
root.addLayout(rec_row)
self.progress = QProgressBar()
self.progress.setRange(0, 1000)
self.progress.setValue(0)
self.progress.setTextVisible(True)
root.addWidget(self.progress)
self.live_label = QLabel()
self.live_label.setWordWrap(True)
self.live_label.setFont(self._mono_font)
root.addWidget(self.live_label)
self.empty_hint = QLabel()
self.empty_hint.setObjectName("hintText")
self.empty_hint.setWordWrap(True)
root.addWidget(self.empty_hint)
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
# stability measure and the point's state.
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setAlternatingRowColors(True)
self.table.setShowGrid(False)
header = self.table.horizontalHeader()
# Every column takes the width its own header and cells need: eliding a
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
# panel scrolls instead.
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setMinimumSectionSize(64)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
self.table.setMinimumHeight(150)
self.table.itemSelectionChanged.connect(self._on_row_selected)
self.table.doubleClicked.connect(self._on_row_activated)
root.addWidget(self.table, 1)
self.fit_label = QLabel()
self.fit_label.setObjectName("hintText")
self.fit_label.setWordWrap(True)
root.addWidget(self.fit_label)
sn_row = QHBoxLayout()
self._sn_label = QLabel()
self._sn_label.setObjectName("fieldLabel")
self.sn_edit = QLineEdit()
self.sn_edit.setPlaceholderText("")
sn_row.addWidget(self._sn_label)
sn_row.addWidget(self.sn_edit, 1)
root.addLayout(sn_row)
self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton()
self.btn_toggle_excluded = QPushButton()
self.btn_toggle_validation = QPushButton()
self.btn_open_record = QPushButton()
self.btn_save_record = QPushButton()
self.btn_export = QPushButton()
self.btn_apply = QPushButton()
self.btn_apply.setObjectName("primaryButton")
for button in (
self.btn_delete,
self.btn_clear_channel,
self.btn_clear_all,
self.btn_toggle_excluded,
self.btn_toggle_validation,
self.btn_open_record,
self.btn_save_record,
):
# The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true")
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
self.btn_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_open_record.clicked.connect(self._on_open_record)
self.btn_save_record.clicked.connect(self._on_save_record)
self.btn_export.clicked.connect(self.export_coefficients)
self.btn_apply.clicked.connect(self.apply_coefficients)
# Two rows, not nine buttons in a row: in one line every label was
# squeezed until it was unreadable ("排 除 / 设 为 验 证 点").
selection_actions = QHBoxLayout()
selection_actions.setSpacing(6)
selection_actions.addWidget(self.btn_toggle_excluded)
selection_actions.addWidget(self.btn_toggle_validation)
selection_actions.addWidget(self.btn_delete)
selection_actions.addWidget(self.btn_clear_channel)
selection_actions.addWidget(self.btn_clear_all)
selection_actions.addStretch(1)
root.addLayout(selection_actions)
file_actions = QHBoxLayout()
file_actions.setSpacing(6)
file_actions.addStretch(1)
file_actions.addWidget(self.btn_open_record)
file_actions.addWidget(self.btn_save_record)
file_actions.addWidget(self.btn_export)
file_actions.addWidget(self.btn_apply)
root.addLayout(file_actions)
self._refresh_enabled()
self._refresh_table()
self.set_theme(theme)
self.retranslate()
# ------------------------------------------------------------------ API
@property
def _prepared(self):
return self._service.prepared
@property
def _record_identity(self):
return self._service.record_identity
def set_theme(self, theme: str) -> None:
self._theme = theme
self._render_live_label()
# --------------------------------------------------------- recorder link
def bind_recorder(self, recorder) -> None:
"""Attach the sampling state machine that owns the recording lifecycle."""
self._recorder = recorder
@property
def _recording(self) -> bool:
return bool(self._recorder is not None and self._recorder.is_recording)
def set_running(self, running: bool) -> None:
self._running = bool(running)
if not self._running and self._recording:
self._recorder.cancel()
self._refresh_enabled()
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
self._sensor_type = str(sensor_type or "box")
self._config_ref = config_ref
# A new run is the live device's context again.
self._service.clear_record_identity()
def set_live(self, fn: float, fx: float, fy: float) -> None:
"""Update the live readout. Sampling is handled by the recorder."""
self._live_fn = float(fn)
self._live_fx = float(fx)
self._live_fy = float(fy)
self._render_live_label()
def set_progress(self, fraction: float, elapsed_s: float, total_s: float) -> None:
self.progress.setVisible(True)
self.progress.setValue(max(0, min(1000, int(fraction * 1000))))
self.progress.setFormat(f"{elapsed_s:.1f} / {total_s:.1f} s")
def add_point(self, point) -> None:
"""Record a finished calibration point (three axes captured)."""
self._rebuild_store()
self._refresh_table()
stats = point.loaded_stats()
if point.status == "few_samples":
self.status_message.emit(t("cal.record_failed_few"))
return
self.status_message.emit(
t("cal.record_ok", weight=f"{point.reference_load_n:g}", raw=f"{stats.mean:.3f}")
)
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
"""Back-compat shim: live readout only, sampling lives in the recorder."""
self.set_live(fn, fx, fy)
def retranslate(self) -> None:
self.title.setText(t("panels.cal"))
self._weight_label.setText(t("cal.weight"))
self._duration_label.setText(t("cal.duration"))
self.btn_record.setText(
t("cal.recording_stop") if self._recording else t("cal.record")
)
self.btn_delete.setText(t("cal.delete_row"))
self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_open_record.setText(t("cal.open_record"))
self.btn_save_record.setText(t("cal.save_record"))
self.btn_export.setText(t("cal.export"))
self.btn_apply.setText(t("cal.apply"))
self.btn_open_record.setToolTip(t("cal.open_record_tip"))
self.btn_save_record.setToolTip(t("cal.save_record_tip"))
self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setToolTip(t("cal.apply_tip"))
self.btn_toggle_excluded.setText(t("cal.state_exclude"))
self.btn_toggle_validation.setText(t("cal.mark_validation"))
self.btn_toggle_excluded.setToolTip(t("cal.state_exclude_tip"))
self.btn_toggle_validation.setToolTip(t("cal.mark_validation_tip"))
self._sn_label.setText(t("cal.sn"))
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
self.empty_hint.setText(t("cal.empty_hint"))
headers = (
t("cal.col_weight"),
t("cal.col_fn"),
t("cal.col_fx"),
t("cal.col_fy"),
t("cal.col_stability"),
t("cal.col_state"),
)
for i, text in enumerate(headers):
item = self.table.horizontalHeaderItem(i)
if item is None:
item = QTableWidgetItem(text)
self.table.setHorizontalHeaderItem(i, item)
else:
item.setText(text)
self._refresh_channel_badge()
self._render_live_label()
self._refresh_enabled()
self._refresh_table()
# ------------------------------------------------------------- internals
def _refresh_channel_badge(self) -> None:
color_hex = color(self._theme, f"plot_{self._channel}")
self.channel_badge.setText(self._channel.upper())
self.channel_badge.setStyleSheet(
f"QLabel#unitBadge {{ color: {color_hex};"
f" border: 1px solid {color(self._theme, 'card_border')};"
f" background-color: {color(self._theme, 'card_bg')}; }}"
)
def _on_channel_button(self, button, checked: bool) -> None:
if not checked:
return
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
key = mapping.get(button)
if key is None:
return
if self._recording:
self._recorder.cancel()
self._channel = key # type: ignore[assignment]
self._refresh_channel_badge()
self._refresh_table()
def _refresh_enabled(self) -> None:
# While recording the button must stay **enabled**: it is the cancel
# affordance. Disabling it here was why a recording could not be stopped.
can_record = self._running
self.btn_record.setEnabled(can_record)
self.btn_record.setText(
t("cal.recording_stop") if self._recording else t("cal.record")
)
self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton")
self.btn_record.style().unpolish(self.btn_record)
self.btn_record.style().polish(self.btn_record)
self.weight_spin.setEnabled(not self._recording)
self.duration_spin.setEnabled(not self._recording)
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
radio.setEnabled(not self._recording)
self.btn_export.setEnabled(not self._recording)
# Saving the audit record is independent of the coefficients, and applying
# is only meaningful once coefficients exist.
self.btn_save_record.setEnabled(not self._recording and self._has_points())
self.btn_open_record.setEnabled(not self._recording)
self.btn_apply.setEnabled(bool(self._last_export_path) and not self._recording)
if self._recording:
self.progress.setVisible(True)
record_hint = t("cal.record_disabled") if not self._running else ""
if not self._recording:
self.btn_record.setToolTip(record_hint)
else:
self.btn_record.setToolTip(t("cal.recording_stop_tip"))
def _render_live_label(self) -> None:
colors = {
"fn": color(self._theme, "plot_fn"),
"fx": color(self._theme, "plot_fx"),
"fy": color(self._theme, "plot_fy"),
}
parts = []
for key, value in (
("fn", self._live_fn),
("fx", self._live_fx),
("fy", self._live_fy),
):
parts.append(
f"<span style='color:{colors[key]}; font-weight:600'>{key.upper()}</span>"
f"&nbsp;{value:.3f}"
)
self.live_label.setText(
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
f"{t('cal.live_raw_label')}</span>&nbsp;&nbsp;" + "&nbsp;&nbsp;&nbsp;".join(parts)
)
def _on_record_clicked(self) -> None:
if self._recorder is None:
return
if self._recording:
self._recorder.cancel()
return
if not self._running:
return
duration = float(self.duration_spin.value())
self._recorder.start(
channel=self._channel,
reference_load_n=float(self.weight_spin.value()),
duration_s=duration,
direction="load",
)
self.progress.setVisible(True)
self.progress.setValue(0)
self.progress.setFormat(f"0.0 / {duration:.1f} s")
self._refresh_enabled()
self.status_message.emit(t("cal.recording"))
def on_record_cancelled(self) -> None:
"""Called by the window when the recorder was cancelled."""
self.progress.setValue(0)
self.progress.setFormat("")
self._refresh_enabled()
self.status_message.emit(t("cal.record_cancelled"))
def on_record_finished(self, point) -> None:
self.progress.setValue(1000)
self.progress.setFormat("")
self._refresh_enabled()
self.add_point(point)
def poll_recorder(self) -> None:
"""Mirror the recorder's progress; the recorder owns the deadline."""
if not self._recording:
return
self.set_progress(
self._recorder.progress(),
self._recorder.elapsed_s(),
self._recorder.requested_duration_s,
)
# ------------------------------------------------------------ diagnostics
def _diagnostic_points(self):
"""The recorder's points for the active channel, or the export store."""
if self._recorder is not None:
return self._recorder.points_for(self._channel)
return []
def _current_point(self):
row = self.table.currentRow()
points = self._diagnostic_points()
if 0 <= row < len(points):
return points[row]
return None
def _refresh_table(self) -> None:
points = self._diagnostic_points()
self.table.setRowCount(len(points))
for row, point in enumerate(points):
values = (
f"{point.reference_load_n:g}",
self._axis_cell(point, "fn"),
self._axis_cell(point, "fx"),
self._axis_cell(point, "fy"),
self._stability_cell(point),
t(point.state_key()),
)
for column, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
if column in (0, 1, 2, 3):
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
else:
item.setTextAlignment(int(Qt.AlignCenter))
if point.excluded:
item.setForeground(QColor(color(self._theme, "text_muted")))
elif point.is_validation:
item.setForeground(QColor(color(self._theme, "info")))
self.table.setItem(row, column, item)
self.empty_hint.setVisible(not points)
self._refresh_fit_label()
@staticmethod
def _axis_cell(point, channel: str) -> str:
value = point.axis_mean(channel)
if not np.isfinite(value):
return "—"
if channel == point.channel and np.isfinite(point.residual_n):
# Residual of the fit on the same cell as the value it came from.
return f"{value:.4f} ({point.residual_n:+.4f})"
return f"{value:.4f}"
@staticmethod
def _stability_cell(point) -> str:
stats = point.loaded_stats()
if not np.isfinite(stats.std):
return "—"
return f"σ {stats.std:.4f} / n {stats.n}"
def _refresh_fit_label(self) -> None:
if self._recorder is None:
self.fit_label.setText("")
return
# Same prepared result the store and the export use — never a second one.
fit = self._recorder.fit(self._channel, prepared=self._prepared)
if not fit.usable:
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
return
# The method and the breakpoint count describe the model that export and
# the device use; the least-squares pair is only an equivalent summary.
text = t(
"cal.fit_summary",
channel=self._channel.upper(),
method=t(f"cal.method_{fit.method}"),
count=fit.n_points,
breakpoints=fit.breakpoints,
residual=f"{fit.max_residual_n:.4f}",
)
if fit.validation_points:
text += " " + t(
"cal.fit_validation",
error=f"{fit.validation_error_n:.4f}",
count=fit.validation_points,
)
self.fit_label.setText(text)
def _on_row_selected(self) -> None:
point = self._current_point()
self.btn_toggle_excluded.setEnabled(point is not None)
self.btn_toggle_validation.setEnabled(point is not None)
if point is not None:
state = t("cal.state_restore") if point.excluded else t("cal.state_exclude")
self.btn_toggle_excluded.setText(state)
def point_dialog(self, point):
"""The diagnostic view for a point, rebuilt from its samples if needed.
A reopened record carries the full sample series, so the three-axis view
can always be produced — waiting for a cached trace would have made
exactly the reopened points (the ones worth auditing) unopenable.
"""
if point is None or not (point.trace or point.samples):
return None
if not point.trace:
from orisys_app_shared.force.calibration_session import (
restore_diagnostics,
)
restore_diagnostics(point)
if not point.trace:
return None
return CalibrationPointDialog(point, theme=self._theme, parent=self)
def _on_row_activated(self) -> None:
"""Double click a row to inspect that point's three-axis curve."""
dialog = self.point_dialog(self._current_point())
if dialog is not None:
dialog.exec_()
def _on_toggle_excluded(self) -> None:
point = self._current_point()
if point is None or self._recorder is None:
return
# Pass the point itself: a table row index would resolve against the
# recorder's cross-channel list and could hit the wrong point.
self._recorder.set_excluded(point, not point.excluded)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
t("cal.excluded" if point.excluded else "cal.restored")
)
def _on_toggle_validation(self) -> None:
point = self._current_point()
if point is None or self._recorder is None:
return
self._recorder.set_validation(point, not point.is_validation)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
t("cal.marked_validation" if point.is_validation else "cal.unmarked_validation")
)
def _export_meta(self) -> dict:
"""The identity/pipeline metadata the export would carry."""
return self._service.export_meta(
sensor_type=self._sensor_type,
config_ref=self._config_ref,
sn=self.sn_edit.text().strip(),
duration_s=float(self.duration_spin.value()),
)
def _rebuild_store(self) -> None:
"""Prepare the session once: store, diagnostics and export share it.
The panel used to filter, aggregate and build its own model while
``CalibrationRecorder.fit`` prepared a second one for the table, so the
displayed residual and the exported curve could describe different
models. Both now read :class:`PreparedCalibration`, built by the service.
"""
self._service.prepare(self._recorder, self._export_meta())
def _on_delete_row(self) -> None:
point = self._current_point()
if point is None or self._recorder is None:
return
# Deleting is an exclusion: the recorded evidence stays, but it leaves
# the fit and the export.
self._recorder.set_excluded(point, True)
self._rebuild_store()
self._refresh_table()
def _on_clear_channel(self) -> None:
if self._recorder is not None:
self._recorder.clear_points(self._channel)
self._rebuild_store()
self._refresh_table()
def _on_clear_all(self) -> None:
if self._recorder is not None:
self._recorder.clear_points()
self._rebuild_store()
self._refresh_table()
def export_coefficients(self) -> None:
"""Write the prepared model to the user's calibration directory.
Public because the sensor dialog offers the same action: one
implementation, reachable from both places.
"""
# Re-prepare first: the SN and the requested duration may have changed,
# and the file must be exactly the model the panel is showing.
self._rebuild_store()
self._refresh_fit_label()
if self._prepared is None or not self._prepared.ok:
QMessageBox.warning(
self,
t("panels.cal"),
t("cal.export_no_model", detail="; ".join(self._warnings())),
)
return
try:
# Write into the user's calibration directory only. Overwriting the
# device bundle under config/sensors/<type>/ is now an explicit,
# separate step so that exporting cannot silently change what the
# next Sensor() will load.
primary, _local_path, warnings = self._service.export_coefficients(
sensor_type=self._sensor_type,
local_bundle_dir=None,
)
except ValueError as exc:
QMessageBox.warning(self, t("panels.cal"), str(exc))
return
except Exception as exc:
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
return
self._last_export_path = str(primary)
self._refresh_enabled()
msg = t("cal.export_ok", path=str(primary))
if warnings:
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
self.status_message.emit(msg)
# The window records which coefficients were exported. Applying them is a
# separate button, so exporting cannot silently change a running device.
self.calibration_exported.emit(str(primary))
QMessageBox.information(self, t("panels.cal"), msg)
def _warnings(self) -> list[str]:
return list(getattr(self._prepared, "warnings", []) or [])
def apply_coefficients(self) -> None:
"""Ask for the last export to be pushed into the running device."""
if not self._last_export_path:
self.status_message.emit(t("cal.apply_no_export"))
return
self.apply_requested.emit(self._last_export_path)
def _on_save_record(self) -> None:
"""Write the session's audit record: every point and every sample."""
if self._recorder is None:
return
points = self._recorder.all_points()
if not points:
self.status_message.emit(t("cal.save_record_empty"))
return
# ``_export_meta`` keeps a reopened record's own device identity.
meta = self._export_meta()
try:
written = self._service.save_record(self._recorder, meta)
except Exception as exc: # noqa: BLE001 - reported to the operator
QMessageBox.warning(
self, t("panels.cal"), t("cal.save_record_failed", error=str(exc))
)
return
samples = sum(len(point.samples) for point in points)
msg = t("cal.save_record_ok", path=str(written), points=len(points), samples=samples)
self.status_message.emit(msg)
QMessageBox.information(self, t("panels.cal"), msg)
def _on_open_record(self, path: str | None = None) -> None:
"""Reopen a saved session: its points **and** its device identity."""
if self._recorder is None:
return
if path is None:
path, _filter = QFileDialog.getOpenFileName(
self,
t("cal.open_record"),
str(calibration_export_dir()),
t("cal.open_record_filter"),
)
if not path:
return
try:
record = self._service.load_record(path)
except Exception as exc: # noqa: BLE001 - reported to the operator
QMessageBox.warning(
self, t("panels.cal"), t("cal.open_record_failed", error=str(exc))
)
return
# Show the record's own SN: it is what a later save or export will carry,
# so the operator must not be looking at the connected device's number.
if record.identity().get("sn"):
self.sn_edit.setText(record.identity()["sn"])
self._recorder.load_points(record.points, replace=True)
self._rebuild_store()
self._refresh_table()
self._refresh_enabled()
msg = t("cal.open_record_ok", path=record.path.name, points=len(record.points))
msg += " " + t("cal.open_record_device", device=record.describe())
if record.warnings:
msg += "\n" + t("cal.open_record_warnings", detail="; ".join(record.warnings))
self.status_message.emit(msg)
def _has_points(self) -> bool:
return bool(self._recorder is not None and self._recorder.all_points())
@@ -0,0 +1,147 @@
"""Inspect one calibration point: its three-axis trace and its diagnostics.
Kept in a dialog rather than inline so the calibration panel can stay narrow in
the three-panel layout, while a single point can still be examined in full.
"""
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QVBoxLayout,
)
from orisys_app_shared.force import NA
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for the calibration point dialog") from exc
TRACE_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
TRACE_KEYS = ("fn", "fx", "fy")
def _mono_font(point_size: int = 9) -> QFont:
families = set(QFontDatabase().families())
chosen = "monospace"
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
if name in families:
chosen = name
break
return QFont(chosen, point_size)
class CalibrationPointDialog(QDialog):
"""One recorded point: all three raw traces plus its fit residual."""
def __init__(self, point, *, theme: str = "dark", parent=None) -> None:
super().__init__(parent)
self._point = point
self._theme = theme
self.setWindowTitle(
t(
"cal.point_title",
channel=point.channel.upper(),
load=f"{point.reference_load_n:g}",
)
)
self.resize(560, 420)
root = QVBoxLayout(self)
root.setSpacing(8)
form = QFormLayout()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(4)
self._add_row(form, "cal.point_channel", point.channel.upper())
self._add_row(form, "cal.point_direction", point.direction)
self._add_row(form, "cal.point_round", str(point.round_index))
self._add_row(
form,
"cal.point_duration",
t(
"cal.point_duration_value",
actual=f"{point.duration_s:.2f}",
requested=f"{point.requested_duration_s:.2f}",
),
)
self._add_row(
form,
"cal.point_samples",
t("cal.point_samples_value", valid=point.n_valid, total=point.n_total),
)
stats = []
for key in TRACE_KEYS:
axis = point.raw.get(key)
if axis is None or not np.isfinite(axis.mean):
stats.append(f"{key.upper()} —")
continue
stats.append(f"{key.upper()} {axis.mean:.4f} ± {axis.std:.4f}")
value = QLabel(" ".join(stats))
value.setFont(_mono_font())
self._add_row(form, "cal.point_axes", "")
form.addRow(value)
residual = point.residual_n
self._add_row(
form,
"cal.point_residual",
"—" if not np.isfinite(residual) else f"{residual:+.4f}",
)
root.addLayout(form)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plot_widget.setBackground(color(theme, "plot_bg"))
root.addWidget(self.plot_widget, 1)
self._build_traces()
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
def _add_row(self, form: QFormLayout, key: str, value: str) -> None:
label = QLabel(t(key))
label.setObjectName("fieldLabel")
field = QLabel(str(value))
form.addRow(label, field)
def _build_traces(self) -> None:
"""One row per axis, stacked, sharing the sample axis."""
pen_axis = pg.mkPen(color(self._theme, "text_secondary"))
first = None
for row, key in enumerate(TRACE_KEYS):
plot = self.plot_widget.addPlot(row=row, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setMouseEnabled(x=True, y=False)
plot.getAxis("left").setPen(pen_axis)
plot.getAxis("left").setTextPen(pen_axis)
for axis_name in ("left", "bottom"):
plot.getAxis(axis_name).setPen(pen_axis)
plot.getAxis(axis_name).setTextPen(pen_axis)
plot.setLabel(
"left", key.upper(), color=color(self._theme, TRACE_TOKENS[row])
)
if first is None:
first = plot
else:
plot.setXLink(first)
self.plot_widget.ci.layout.setRowStretchFactor(row, 1)
values = self._point.trace.get(key)
if not values:
continue
step = self._point.trace_dt_s or 0.01
times = np.arange(len(values), dtype=float) * step
plot.plot(
times,
np.asarray(values, dtype=float),
pen=pg.mkPen(color(self._theme, TRACE_TOKENS[row]), width=2),
)
@@ -0,0 +1,26 @@
"""Labels that may be squeezed below their text width.
A ``QLabel`` reports its text width as a *minimum*, so one long annotation can
decide the minimum width of a whole panel (or of the top rail) and push its
neighbours out of view. These labels keep their size hint — they ask for the
space their text needs — but allow the layout to shrink them, at which point Qt
clips the text and the tooltip remains the way to read it.
"""
from __future__ import annotations
from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QLabel, QSizePolicy
class ShrinkableLabel(QLabel):
"""A label whose minimum width is 0 (the text is clipped when squeezed)."""
def __init__(self, text: str = "", parent=None) -> None:
super().__init__(text, parent)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
self.setMinimumWidth(0)
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
hint = super().minimumSizeHint()
return QSize(0, hint.height())
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
"""Small monochrome icons, painted at runtime from the theme tokens.
The app ships no icon assets beyond its logo, and Qt's built-in icons follow the
platform's own look. These three chart-header actions are therefore drawn here:
a transparent background and a pen colour taken from the theme, so the header
stays readable in both themes without a single hex literal in widget code.
The cache is keyed on the *resolved colour*, not on the theme name: a theme
switch therefore produces new pixmaps, and two themes that share a colour share
the pixmap.
"""
from __future__ import annotations
from functools import lru_cache
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest", "settings")
ICON_SIZE = 16
_PEN_WIDTH = 1.4
def _paint_zoom_fit(painter: QPainter, size: int) -> None:
"""Four corner brackets around a small frame: "fit this to the screen"."""
inset = 2.0
arm = size * 0.28
far = size - inset
for x, y, dx, dy in (
(inset, inset, 1, 1),
(far, inset, -1, 1),
(inset, far, 1, -1),
(far, far, -1, -1),
):
painter.drawLine(
int(round(x)), int(round(y)), int(round(x + dx * arm)), int(round(y))
)
painter.drawLine(
int(round(x)), int(round(y)), int(round(x)), int(round(y + dy * arm))
)
side = size * 0.375
painter.drawRect(QRectF((size - side) / 2.0, (size - side) / 2.0, side, side))
def _paint_box_zoom(painter: QPainter, size: int) -> None:
"""A dashed frame: drag a region to zoom into it."""
pen = painter.pen()
pen.setStyle(Qt.DashLine)
pen.setDashPattern([2.2, 1.6])
painter.setPen(pen)
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
def _paint_latest(painter: QPainter, size: int) -> None:
"""An arrow running into a bar: jump to the newest sample."""
top = 3.0
bottom = size - 3.0
mid = size / 2.0
bar_x = size - 3.0
draw_to = bar_x - 2.5
painter.drawLine(int(round(bar_x)), int(round(top)), int(round(bar_x)), int(round(bottom)))
painter.drawLine(2, int(round(mid)), int(round(draw_to)), int(round(mid)))
head = size * 0.26
painter.drawLine(
int(round(draw_to)), int(round(mid)),
int(round(draw_to - head)), int(round(mid - head)),
)
painter.drawLine(
int(round(draw_to)), int(round(mid)),
int(round(draw_to - head)), int(round(mid + head)),
)
def _paint_settings(painter: QPainter, size: int) -> None:
"""A cog: eight teeth around a ring, with the hole left open."""
import math
centre = size / 2.0
ring = size * 0.20
tooth = size * 0.34
painter.drawEllipse(QRectF(centre - ring, centre - ring, ring * 2, ring * 2))
for index in range(8):
angle = index * math.pi / 4.0
dx, dy = math.cos(angle), math.sin(angle)
painter.drawLine(
int(round(centre + dx * ring)), int(round(centre + dy * ring)),
int(round(centre + dx * tooth)), int(round(centre + dy * tooth)),
)
_PAINTERS = {
"zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom,
"latest": _paint_latest,
"settings": _paint_settings,
}
@lru_cache(maxsize=32)
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
canvas = QPixmap(size, size)
canvas.fill(Qt.transparent)
paint = _PAINTERS.get(str(name))
if paint is None:
return canvas
painter = QPainter(canvas)
painter.setRenderHint(QPainter.Antialiasing, True)
pen = QPen()
pen.setWidthF(_PEN_WIDTH)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
pen.setColor(QColor(tint))
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
try:
paint(painter, size)
finally:
painter.end()
return canvas
def icon(name: str, theme: str, *, token: str = "text_secondary") -> QIcon:
"""The named icon in the theme's colour for ``token``."""
return QIcon(pixmap(name, color(theme, token)))
def clear() -> None:
"""Drop the cached pixmaps (used by tests and by a theme rebuild)."""
pixmap.cache_clear()
@@ -13,15 +13,23 @@ class Mesh3dPanel(QFrame):
super().__init__(parent)
self.setObjectName("contentPanel")
layout = QVBoxLayout(self)
header = QVBoxLayout()
header.setSpacing(2)
self.title = QLabel()
self.title.setObjectName("panelTitle")
header.addWidget(self.title)
layout.addLayout(header)
self.body = QLabel()
self.body.setObjectName("hintText")
self.body.setAlignment(Qt.AlignCenter)
self.body.setWordWrap(True)
layout.addWidget(self.title)
layout.addWidget(self.body, 1)
self.retranslate()
def retranslate(self) -> None:
self.title.setText(t("panels.mesh3d"))
self.body.setText(t("panels.mesh3d_hint"))
self.body.setText(t("panels.mesh3d_empty"))
self.body.setToolTip(t("panels.mesh3d_empty_tip"))
@@ -0,0 +1,392 @@
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
Two things live here because they are answered together, at the chart: how much
time and range the curves show, and what the acquisition is allowed to filter.
The filter half is a *request*: it is handed to the SDK through
``FilterSettings.apply_to`` at the start of the next session, and what the device
reports as done is what the record will say (see ``force/filters.py``).
"""
from __future__ import annotations
from PyQt5.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force.filters import (
SUGGESTED_LOWPASS_HZ,
FilterSettings,
available_presets,
)
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
# stays sub-pixel while a full contact still fits (see prefs store).
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
class PlotSettingsDialog(QDialog):
def __init__(
self,
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(420, 520)
self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the
#: "edited -> custom" handler does not fire on our own writes.
self._syncing_filters = False
root = QVBoxLayout(self)
form = QFormLayout()
self.window_label = QLabel()
self.window_spin = QDoubleSpinBox()
self.window_spin.setRange(1.0, 30.0)
self.window_spin.setSingleStep(1.0)
self.window_spin.setDecimals(0)
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
self.fn_max_spin.setSingleStep(1.0)
self.fn_max_spin.setDecimals(0)
self.fn_max_spin.setSuffix(" N")
self.fn_max_spin.setValue(float(fn_max))
self.shear_max_label = QLabel()
self.shear_max_spin = QDoubleSpinBox()
self.shear_max_spin.setRange(1.0, 100.0)
self.shear_max_spin.setSingleStep(1.0)
self.shear_max_spin.setDecimals(0)
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
# Raw limits: 0 means "auto range on the data".
self.fn_raw_max_label = QLabel()
self.fn_raw_max_spin = QDoubleSpinBox()
self.fn_raw_max_spin.setRange(0.0, 1e9)
self.fn_raw_max_spin.setSingleStep(50000.0)
self.fn_raw_max_spin.setDecimals(0)
self.fn_raw_max_spin.setValue(float(fn_raw_max))
self.shear_raw_max_label = QLabel()
self.shear_raw_max_spin = QDoubleSpinBox()
self.shear_raw_max_spin.setRange(0.0, 1e9)
self.shear_raw_max_spin.setSingleStep(50000.0)
self.shear_raw_max_spin.setDecimals(0)
self.shear_raw_max_spin.setValue(float(shear_raw_max))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
raw_widgets = (
self.fn_raw_max_spin,
self.shear_raw_max_spin,
self.fn_raw_max_label,
self.shear_raw_max_label,
)
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
self.shear_max_label):
widget.setEnabled(newton)
for widget in raw_widgets:
widget.setEnabled(not newton)
self._build_filter_section(root, filters or FilterSettings(), filters_locked)
buttons_row = QHBoxLayout()
self.btn_reset = QPushButton()
self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box)
root.addLayout(buttons_row)
self.retranslate()
# ---------------------------------------------------------------- filters
def _build_filter_section(
self, root: QVBoxLayout, filters: FilterSettings, locked: bool
) -> None:
"""The filter half: a preset, its values, and what they cost."""
self.filters_group = QGroupBox()
group_form = QFormLayout(self.filters_group)
# The preset is shown by its own name — ``balanced`` is the string that
# goes into ``sensor.json``, so the dialog and the config use one word for
# one thing.
self.preset_combo = QComboBox()
self._preset_names = list(available_presets())
for name in self._preset_names:
self.preset_combo.addItem(name)
self.preset_combo.addItem(t("dialog.filter_preset_custom"))
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
self.flow_ema_spin = QDoubleSpinBox()
self.flow_ema_spin.setRange(0.05, 1.0)
self.flow_ema_spin.setSingleStep(0.05)
self.flow_ema_spin.setDecimals(2)
self.flow_ema_spin.setValue(float(filters.flow_ema_alpha))
self.median_spin = QSpinBox()
self.median_spin.setRange(1, 31)
self.median_spin.setValue(int(filters.median_window))
self.lowpass_spin = QDoubleSpinBox()
self.lowpass_spin.setRange(0.0, 60.0)
self.lowpass_spin.setSingleStep(1.0)
self.lowpass_spin.setDecimals(1)
self.lowpass_spin.setSuffix(" Hz")
self.lowpass_spin.setValue(float(filters.lowpass_hz))
self.lowpass_order_spin = QSpinBox()
self.lowpass_order_spin.setRange(1, 6)
self.lowpass_order_spin.setValue(int(filters.lowpass_order))
self.rate_auto_check = QCheckBox()
self.rate_auto_check.setChecked(str(filters.sample_rate_hz) == "auto")
self.rate_spin = QDoubleSpinBox()
self.rate_spin.setRange(1.0, 240.0)
self.rate_spin.setSingleStep(1.0)
self.rate_spin.setDecimals(1)
self.rate_spin.setSuffix(" Hz")
rate = filters.sample_rate_hz
self.rate_spin.setValue(
float(rate) if str(rate) != "auto" else float(FORCE_SAMPLE_RATE_HZ)
)
self.rate_spin.setEnabled(not self.rate_auto_check.isChecked())
self.rate_auto_check.toggled.connect(
lambda checked: self.rate_spin.setEnabled(not checked)
)
self.filter_preset_label = QLabel()
self.flow_ema_label = QLabel()
self.median_label = QLabel()
self.lowpass_label = QLabel()
self.lowpass_order_label = QLabel()
self.rate_label = QLabel()
group_form.addRow(self.filter_preset_label, self.preset_combo)
group_form.addRow(self.flow_ema_label, self.flow_ema_spin)
group_form.addRow(self.median_label, self.median_spin)
group_form.addRow(self.lowpass_label, self.lowpass_spin)
group_form.addRow(self.lowpass_order_label, self.lowpass_order_spin)
group_form.addRow(self.rate_auto_check, self.rate_spin)
for widget in (self.flow_ema_spin, self.median_spin, self.lowpass_spin,
self.rate_spin, self.rate_auto_check):
widget.setToolTip("") # filled in by retranslate()
self.flow_ema_spin.valueChanged.connect(self._on_filter_value_edited)
self.median_spin.valueChanged.connect(self._on_filter_value_edited)
self.lowpass_spin.valueChanged.connect(self._on_filter_value_edited)
self.lowpass_order_spin.valueChanged.connect(self._on_filter_value_edited)
self.rate_spin.valueChanged.connect(self._on_filter_value_edited)
self.rate_auto_check.toggled.connect(self._on_filter_value_edited)
# Start on the preset the settings came from, or on "custom" when they
# are a shape no preset has (a config edited by hand, a read-back state).
# A named preset also *means* its values: showing the name next to
# different numbers would be the same lie the config file used to tell.
if filters.preset in self._preset_names:
self._on_preset_chosen(self._preset_names.index(filters.preset))
self._show_preset(filters.preset)
self.filter_note = QLabel()
self.filter_note.setObjectName("hintText")
self.filter_note.setWordWrap(True)
self.filter_warning = QLabel()
self.filter_warning.setObjectName("warnText")
self.filter_warning.setWordWrap(True)
root.addWidget(self.filters_group)
root.addWidget(self.filter_note)
root.addWidget(self.filter_warning)
self._filters_locked = bool(locked)
self.filters_group.setEnabled(not self._filters_locked)
self._refresh_filter_note()
def _show_preset(self, name: str | None) -> None:
"""Point the combo at a preset (or at "custom") without re-applying it."""
index = self._preset_names.index(name) if name in self._preset_names else -1
self._syncing_filters = True
try:
self.preset_combo.setCurrentIndex(
index if index >= 0 else self.preset_combo.count() - 1
)
finally:
self._syncing_filters = False
def _on_preset_chosen(self, index: int) -> None:
if self._syncing_filters:
return
if not (0 <= index < len(self._preset_names)):
return # "custom": leave the numbers as the operator left them
values = available_presets()[self._preset_names[index]]
self._syncing_filters = True
try:
self.flow_ema_spin.setValue(float(values.get("flow_ema_alpha", 1.0)))
self.median_spin.setValue(int(values.get("force_median_window", 1)))
self.lowpass_spin.setValue(float(values.get("force_lowpass_cutoff_hz", 0.0)))
finally:
self._syncing_filters = False
self._refresh_filter_note()
def _on_filter_value_edited(self, *_args) -> None:
if self._syncing_filters:
return
# Any hand edit is no longer the preset the combo used to name.
self._show_preset(None)
self._refresh_filter_note()
def selected_preset(self) -> str | None:
"""The preset the values still are, or ``None`` for a custom shape."""
index = self.preset_combo.currentIndex()
if 0 <= index < len(self._preset_names):
return self._preset_names[index]
return None
def filter_values(self) -> FilterSettings:
rate: float | str = "auto"
if not self.rate_auto_check.isChecked():
rate = float(self.rate_spin.value())
# ``show_unfiltered`` is deliberately not offered here yet: the second
# (unfiltered) buffer is not wired to the cards or the CSV, and a switch
# that promises a curve it does not draw is worse than no switch. The
# field is kept in the settings (and in the record) until it lands.
return FilterSettings(
flow_ema_alpha=float(self.flow_ema_spin.value()),
median_window=int(self.median_spin.value()),
lowpass_hz=float(self.lowpass_spin.value()),
lowpass_order=int(self.lowpass_order_spin.value()),
sample_rate_hz=rate,
show_unfiltered=False,
preset=self.selected_preset(),
).normalised()
def _refresh_filter_note(self) -> None:
"""Say what these numbers amount to, and where they would not work."""
if not hasattr(self, "filter_note"):
return # still being built
settings = self.filter_values()
rate = self._sample_rate_hz
if str(settings.sample_rate_hz) != "auto":
rate = float(settings.sample_rate_hz)
issue = settings.nyquist_issue(rate)
self.filter_warning.setText(
""
if issue is None
else t(
"dialog.filter_nyquist",
cutoff=f"{issue['cutoff_hz']:g}",
nyquist=f"{issue['nyquist_hz']:g}",
rate=f"{issue['rate_hz']:g}",
)
)
self.filter_warning.setVisible(issue is not None)
if self._filters_locked:
self.filter_note.setText(t("dialog.filter_locked"))
elif settings.flow_ema_on:
self.filter_note.setText(t("dialog.filter_measurement_note"))
elif not settings.active():
self.filter_note.setText(t("dialog.filter_off"))
else:
self.filter_note.setText("")
def values(self) -> tuple[float, float, float, float, float]:
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
float(self.shear_max_spin.value()),
float(self.fn_raw_max_spin.value()),
float(self.shear_raw_max_spin.value()),
)
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Reset" means the shipped defaults, and those filter nothing — the same
# answer the dialog gives for a preference file that says nothing.
self.flow_ema_spin.setValue(1.0)
self.median_spin.setValue(1)
self.lowpass_spin.setValue(0.0)
self.lowpass_order_spin.setValue(2)
self.rate_auto_check.setChecked(True)
self._show_preset("off")
self._refresh_filter_note()
def retranslate(self) -> None:
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
self.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset"))
self.preset_combo.setToolTip(t("dialog.filter_preset_tip"))
self.preset_combo.setItemText(self.preset_combo.count() - 1, t("dialog.filter_preset_custom"))
self.flow_ema_label.setText(t("dialog.filter_flow_ema"))
self.flow_ema_spin.setToolTip(t("dialog.filter_flow_ema_tip"))
self.median_label.setText(t("dialog.filter_median"))
self.median_spin.setToolTip(t("dialog.filter_median_tip"))
self.lowpass_label.setText(t("dialog.filter_lowpass"))
self.lowpass_spin.setToolTip(t("dialog.filter_lowpass_tip"))
self.lowpass_order_label.setText(t("dialog.filter_order"))
self.lowpass_order_spin.setToolTip(t("dialog.filter_order_tip"))
self.rate_auto_check.setText(t("dialog.filter_rate_auto"))
self.rate_auto_check.setToolTip(t("dialog.filter_rate_auto_tip"))
self.rate_label.setText(t("dialog.filter_rate"))
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
self._refresh_filter_note()
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
if ok_btn is not None:
ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None:
cancel_btn.setText(t("dialog.cancel"))
@@ -0,0 +1,204 @@
"""Record status strip: one truthful, always-visible readout of the record channel.
The strip answers four questions the record button alone cannot:
* is this session *acquiring* or also *recording* (the phase rail says the first);
* for how long has the recording been running;
* did the writer drop anything;
* did a write fail.
All of that comes from :class:`~orisys_app_shared.record.experiment.RecordStats`,
which the recorder exposes as a locked snapshot, so the UI can read it on its own
timer without touching the writer thread.
"""
from __future__ import annotations
from PyQt5.QtWidgets import QLabel
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
#: The recording channel is idle, never started in this session.
IDLE = "idle"
#: A recording is running; the elapsed time ticks every UI frame.
RECORDING = "recording"
#: The writer is still closing its files — never present that as finished.
CLOSING = "closing"
#: A write failed, or frames were rejected after a failure.
ERROR = "error"
#: A finished recording (of this session) is being summarised.
DONE = "done"
STATES = (IDLE, RECORDING, CLOSING, ERROR, DONE)
#: Which theme token colours the chip in each state.
_STATE_TOKENS = {
IDLE: "status_idle",
RECORDING: "status_running",
CLOSING: "status_connecting",
ERROR: "status_error",
DONE: "status_idle",
}
_STATE_KEYS = {
IDLE: "record.state_idle",
RECORDING: "record.state_recording",
CLOSING: "record.state_closing",
ERROR: "record.state_error",
DONE: "record.state_done",
}
def format_elapsed(seconds: object) -> str:
"""``mm:ss.d`` for a duration in seconds; never negative, never raising."""
try:
value = max(0.0, float(seconds)) # type: ignore[arg-type]
except (TypeError, ValueError):
value = 0.0
minutes, rest = divmod(value, 60.0)
return f"{int(minutes):02d}:{rest:04.1f}"
class RecordStatusChip(QLabel):
"""Compact record-state chip (text + colour), stable under 60 Hz updates."""
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self._theme = str(theme)
self._state = IDLE
self._duration_s = 0.0
self._frames = 0
self._segments = 0
self._dropped = 0
self._dropped_data = 0
self._dropped_video = 0
self._dropped_gaps = 0
self._errors = 0
self._detail = ""
self._directory = ""
self._token = ""
self._tooltip_cache = ""
self.setObjectName("recordChip")
self._refresh()
# ------------------------------------------------------------------ API
def state(self) -> str:
return self._state
def stats(self) -> dict:
"""The raw numbers behind the current text (used by tooltips/tests)."""
return {
"state": self._state,
"duration_s": self._duration_s,
"frames": self._frames,
"segments": self._segments,
"dropped": self._dropped,
"dropped_data": self._dropped_data,
"dropped_video": self._dropped_video,
"dropped_gaps": self._dropped_gaps,
"errors": self._errors,
"detail": self._detail,
"directory": self._directory,
}
def set_state(
self,
state: str,
*,
duration_s: float = 0.0,
frames: int = 0,
segments: int = 0,
dropped: int = 0,
dropped_data: int = 0,
dropped_video: int = 0,
dropped_gaps: int = 0,
errors: int = 0,
detail: str = "",
directory: str = "",
) -> None:
"""Update every number the strip can show, then repaint if it changed."""
self._state = state if state in STATES else IDLE
self._duration_s = float(duration_s or 0.0)
self._frames = int(frames or 0)
self._segments = int(segments or 0)
self._dropped = int(dropped or 0)
self._dropped_data = int(dropped_data or 0)
self._dropped_video = int(dropped_video or 0)
self._dropped_gaps = int(dropped_gaps or 0)
self._errors = int(errors or 0)
self._detail = str(detail or "")
self._directory = str(directory or "")
self._refresh()
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self._refresh()
def set_directory(self, directory: str) -> None:
"""Where the recording is being written (shown in the tooltip)."""
self._directory = str(directory or "")
self._refresh()
def retranslate(self) -> None:
self._refresh()
# ------------------------------------------------------------- internals
def _text_for_state(self) -> str:
if self._state == RECORDING:
return t("record.state_recording", duration=format_elapsed(self._duration_s))
if self._state == CLOSING:
return t("record.state_closing")
if self._state == ERROR:
return t("record.state_error", count=self._errors)
if self._state == DONE:
return t("record.state_done", frames=self._frames)
return t("record.state_idle")
def _tooltip(self) -> str:
if self._state == IDLE:
return t("record.strip_idle")
return t(
"record.strip_tip",
state=self._text_for_state(),
frames=self._frames,
segments=self._segments,
duration=format_elapsed(self._duration_s),
dropped=self._dropped,
dropped_data=self._dropped_data,
dropped_video=self._dropped_video,
dropped_gaps=self._dropped_gaps,
errors=self._errors,
detail=self._detail or t("record.strip_no_error"),
path=self._directory or t("record.strip_no_path"),
)
def _leading_token(self) -> str:
# Anything dropped is worth warning about even while the state is green;
# a write failure outranks it.
if self._state == ERROR:
return _STATE_TOKENS[ERROR]
if self._dropped > 0:
return "warning"
return _STATE_TOKENS[self._state]
def _refresh(self) -> None:
text = self._text_for_state()
if self._dropped > 0:
text = f"{text} · {t('record.dropped', count=self._dropped)}"
# ``setText`` on every frame would re-layout the rail 60 times a second.
if text != self.text():
self.setText(text)
tooltip = self._tooltip()
if tooltip != self._tooltip_cache:
self._tooltip_cache = tooltip
self.setToolTip(tooltip)
token = self._leading_token()
if token != self._token:
# Re-parsing a stylesheet per frame is measurable; the token only
# changes when the state (or the theme) does.
self._token = token
self.setStyleSheet(
f"QLabel#recordChip {{ color: {color(self._theme, token)};"
" background: transparent; }"
)
@@ -0,0 +1,203 @@
"""Sensor and calibration dialog.
The status rail shows one summary button (``BOX · box/sensor.json``): which
product and config the window is configured for is worth reading at a glance,
but the pickers themselves — and the calibration that belongs to them — need
more room than a 26 px rail can give.
The calibration half is read-only on purpose: it states which calibration the
*device* confirmed, which axes it covers, and what the last exported
coefficients file says it belongs to. Exporting and applying stay single-entry
actions (they are the calibration panel's own methods), so there is exactly one
implementation of each.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_studio.ui.fit_text import ShrinkableLabel
class SensorDialog(QDialog):
"""Sensor type / config picker plus the calibration facts that follow it."""
def __init__(self, *, window, picker, theme: str = "dark") -> None:
super().__init__(window)
self._window = window
self._theme = str(theme)
self.picker = picker
self.setObjectName("sensorDialog")
self.setModal(False)
root = QVBoxLayout(self)
root.setSpacing(10)
self.picker_row = QHBoxLayout()
self.picker_row.addWidget(self.picker)
self.picker_row.addStretch(1)
root.addLayout(self.picker_row)
root.addWidget(self._section_rule())
self.calib_title = QLabel()
self.calib_title.setObjectName("panelTitle")
root.addWidget(self.calib_title)
self.version_label = ShrinkableLabel()
self.version_label.setObjectName("panelMeta")
self.axes_label = ShrinkableLabel()
self.axes_label.setObjectName("panelMeta")
self.config_label = ShrinkableLabel()
self.config_label.setObjectName("panelMeta")
for label in (self.version_label, self.axes_label):
root.addWidget(label)
config_row = QHBoxLayout()
config_row.addWidget(self.config_label)
self.btn_reveal = QPushButton()
self.btn_reveal.setProperty("compact", "true")
self.btn_reveal.clicked.connect(self._window._on_reveal_config)
config_row.addWidget(self.btn_reveal)
config_row.addStretch(1)
root.addLayout(config_row)
root.addWidget(self._section_rule())
self.export_title = QLabel()
self.export_title.setObjectName("panelTitle")
root.addWidget(self.export_title)
self.export_label = ShrinkableLabel()
self.export_label.setObjectName("panelMeta")
self.identity_label = ShrinkableLabel()
self.identity_label.setObjectName("panelMeta")
self.export_label.setWordWrap(True)
self.identity_label.setWordWrap(True)
root.addWidget(self.export_label)
root.addWidget(self.identity_label)
actions = QHBoxLayout()
actions.setSpacing(6)
self.btn_export = QPushButton()
self.btn_export.clicked.connect(self._on_export)
self.btn_apply = QPushButton()
self.btn_apply.clicked.connect(self._on_apply)
self.btn_close = QPushButton()
self.btn_close.setProperty("compact", "true")
self.btn_close.clicked.connect(self.close)
actions.addWidget(self.btn_export)
actions.addWidget(self.btn_apply)
actions.addStretch(1)
actions.addWidget(self.btn_close)
root.addLayout(actions)
# The picker's own changes change the calibration context, so the dialog
# follows them while it is open.
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
self.picker.config_changed.connect(lambda _value: self.refresh())
self.retranslate()
self.refresh()
@staticmethod
def _section_rule() -> QFrame:
rule = QFrame()
rule.setObjectName("sectionSeparator")
rule.setFrameShape(QFrame.HLine)
rule.setFixedHeight(1)
return rule
# ------------------------------------------------------------------ actions
def _on_export(self) -> None:
self._window.cal_panel.export_coefficients()
self.refresh()
def _on_apply(self) -> None:
self._window.cal_panel.apply_coefficients()
self.refresh()
# ------------------------------------------------------------------ content
def refresh(self) -> None:
"""Re-read the shell's calibration state. Called on open and on change."""
window = self._window
version = str(getattr(window, "_active_calibration_id", "") or "")
self.version_label.setText(
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
)
axes = ""
summary = getattr(window, "_calibration_summary", None)
if callable(summary):
axes = str(summary())
self.axes_label.setText(f"{t('sensor.calib_axes')}: {axes or t('sensor.calib_none')}")
config_ref = ""
resolve = getattr(window, "_resolve_config_ref", None)
if callable(resolve):
config_ref = str(resolve() or "")
self.config_label.setText(
f"{t('sensor.calib_config')}: "
f"{Path(config_ref).name if config_ref else t('status.config_none')}"
)
self.btn_reveal.setEnabled(bool(config_ref))
export = str(getattr(window, "_last_calibration_export", "") or "")
if not export:
self.export_label.setText(t("sensor.export_none"))
self.identity_label.setText("")
self.btn_apply.setEnabled(False)
return
path = Path(export)
self.export_label.setText(
t(
"sensor.export_file",
name=path.name,
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
time=(
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
if path.is_file()
else "?"
),
)
)
identity = {}
read_identity = getattr(window, "calibration_identity", None)
if callable(read_identity):
identity = dict(read_identity(export) or {})
self.identity_label.setText(
t(
"sensor.export_identity",
sensor_type=identity.get("sensor_type") or "?",
sn=identity.get("sn") or "?",
config=Path(str(identity.get("config_ref") or "?")).name,
)
)
self.btn_apply.setEnabled(True)
def retranslate(self) -> None:
self.setWindowTitle(t("sensor.dialog_title"))
self.calib_title.setText(t("sensor.calib_section"))
self.export_title.setText(t("sensor.export_section"))
self.btn_reveal.setText(t("menu.reveal_config"))
self.btn_export.setText(t("cal.export"))
self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setText(t("cal.apply"))
self.btn_apply.setToolTip(t("cal.apply_tip"))
self.btn_close.setText(t("dialog.cancel"))
self.picker.retranslate()
self.refresh()
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
# Every open re-reads the shell: the device may have confirmed a
# calibration since the dialog was last shown.
self.refresh()
super().showEvent(event)
@@ -1,13 +1,32 @@
"""Sensor type + config picker with thumbnail placeholder."""
"""Sensor type + config picker.
One row, because it lives at the left of the status rail: a badge naming the
selected product, then the product/sensor choice and the config file that
belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is
what decides which configs exist, so the two sit next to each other with their
own labels and tooltips instead of a bare "配置" combo.
"""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from PyQt5.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QSizePolicy,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
CONTROL_HEIGHT = 30
#: Both combos stay narrow: this row shares one line with the session state.
TYPE_WIDTH = 76
CONFIG_WIDTH = 120
BADGE_WIDTH = 46
class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str)
@@ -17,39 +36,44 @@ class SensorPickerWidget(QWidget):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.setSpacing(6)
self.thumb = QFrame()
self.thumb.setObjectName("contentPanel")
self.thumb.setFixedSize(72, 72)
thumb_layout = QVBoxLayout(self.thumb)
#: The product badge that used to sit above the combos. Kept as an
#: attribute: it states which sensor the whole window is configured for.
self.thumb_label = QLabel()
self.thumb_label.setObjectName("unitBadge")
self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setWordWrap(True)
thumb_layout.addWidget(self.thumb_label)
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
self.thumb_label.setProperty("rail", "true")
# The old name, kept so callers/tests that hide or tooltip the product
# badge keep working.
self.thumb = self.thumb_label
form = QVBoxLayout()
form.setSpacing(4)
type_row = QHBoxLayout()
self.type_label = QLabel()
self.type_label.setObjectName("fieldLabel")
self.type_combo = QComboBox()
self.type_combo.setMinimumWidth(TYPE_WIDTH)
self.type_combo.setProperty("rail", "true")
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
type_row.addWidget(self.type_label)
type_row.addWidget(self.type_combo, 1)
config_row = QHBoxLayout()
self.config_label = QLabel()
self.config_label.setObjectName("fieldLabel")
self.config_combo = QComboBox()
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
self.config_combo.setProperty("rail", "true")
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
config_row.addWidget(self.config_label)
config_row.addWidget(self.config_combo, 1)
form.addLayout(type_row)
form.addLayout(config_row)
layout.addWidget(self.thumb)
layout.addLayout(form, 1)
layout.addWidget(self.thumb_label)
layout.addWidget(self.type_label)
layout.addWidget(self.type_combo)
layout.addWidget(self.config_label)
layout.addWidget(self.config_combo, 1)
self._reload_configs()
self._refresh_thumb()
self.retranslate()
@@ -88,6 +112,11 @@ class SensorPickerWidget(QWidget):
self.config_combo.setCurrentIndex(max(0, idx))
self.config_combo.blockSignals(False)
def set_picker_enabled(self, enabled: bool) -> None:
"""Enable/disable both pickers (used while a session is running)."""
self.type_combo.setEnabled(bool(enabled))
self.config_combo.setEnabled(bool(enabled))
def _reload_configs(self) -> None:
self.config_combo.blockSignals(True)
self.config_combo.clear()
@@ -98,8 +127,7 @@ class SensorPickerWidget(QWidget):
self.config_combo.blockSignals(False)
def _refresh_thumb(self) -> None:
name = self.sensor_type().upper()
self.thumb_label.setText(name)
self.thumb_label.setText(self.sensor_type().upper())
def _on_type_index_changed(self, _index: int) -> None:
self._reload_configs()
@@ -113,7 +141,9 @@ class SensorPickerWidget(QWidget):
def retranslate(self) -> None:
self.type_label.setText(t("sensor.type"))
self.config_label.setText(t("sensor.config"))
if not self.thumb_label.text().strip():
self.thumb_label.setText(t("sensor.thumb_placeholder"))
else:
self._refresh_thumb()
self.type_label.setToolTip(t("sensor.type_tip"))
self.config_label.setToolTip(t("sensor.config_tip"))
self.type_combo.setToolTip(t("sensor.type_tip"))
self.config_combo.setToolTip(t("sensor.config_tip"))
self.thumb_label.setToolTip(t("sensor.thumb_placeholder"))
self._refresh_thumb()
+701
View File
@@ -0,0 +1,701 @@
"""Top of the studio shell: status rail and a collapsible settings area.
The rail is what an operator watches while a session runs — phase, device,
calibration, data validity and the record channel — plus the three actions that
must never be more than one click away (record, run/stop, reset).
Everything that is *configuration* rather than state — source, camera number,
video file, units and the sensor/config pickers — lives in the settings area,
collapsed by default. Both live inside one scroll area so a narrow window
scrolls sideways instead of squeezing the controls into an unreadable row.
"""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from orisys_studio.ui.fit_text import ShrinkableLabel
from orisys_studio.ui.record_status import RecordStatusChip
CONTROL_HEIGHT = 30
BUTTON_HEIGHT = 32
#: Recording modes, in combo order. ``value`` is what the app consumes.
RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.mode_data_only"))
def _separator() -> QFrame:
line = QFrame()
line.setObjectName("sectionSeparator")
line.setFrameShape(QFrame.VLine)
line.setFixedWidth(1)
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
return line
def _rail(widget: QWidget) -> QWidget:
"""Mark a control as belonging to the thin status rail.
The rail's height is the app's first visual weight statement: without this
property every button would keep the dialog padding and the rail would grow
to twice its content.
"""
widget.setProperty("rail", "true")
return widget
def _group(*widgets: QWidget) -> QWidget:
box = QWidget()
layout = QHBoxLayout(box)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
for widget in widgets:
layout.addWidget(widget)
return box
class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
#: The sensor summary button: opens the sensor and calibration dialog.
sensor_clicked = pyqtSignal()
#: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal()
#: Replay view: pick the recorded video to play.
replay_video_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
record_toggled = pyqtSignal(bool)
#: The settings area was expanded or collapsed by the user.
settings_toggled = pyqtSignal(bool)
def __init__(
self,
parent=None,
*,
theme: str = "dark",
) -> None:
super().__init__(parent)
self._theme = str(theme)
self._run_tooltip_reason = ""
self._run_available = True
self._run_phase = "idle"
self._settings_expanded = False
#: "camera" (the default) or "video": an offline acquisition run fed from
#: a video file. There is no source *combobox* any more — the view
#: decides which picker is on screen, and the file menu switches modes.
self._source_mode = "camera"
self._source_view = "live"
self._source_controls_enabled = True
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
outer.setSpacing(4)
# The rail is always visible and never scrolls: it is one instrument
# line, and a scroll area around it would reserve a scrollbar's height
# (30 px in the Fusion metrics) even when nothing scrolls.
outer.addLayout(self._build_rail())
# Only the settings area scrolls; it is the part that can outgrow a
# narrow window.
self._settings_scroll = QScrollArea()
self._settings_scroll.setWidgetResizable(True)
self._settings_scroll.setFrameShape(QFrame.NoFrame)
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.settings_page = self._build_settings()
self._settings_scroll.setWidget(self.settings_page)
outer.addWidget(self._settings_scroll)
self._populate_units_combo()
self._populate_record_modes()
self.retranslate()
self._apply_source_mode_ui()
self.set_settings_expanded(False)
# --------------------------------------------------------------- building
def _build_rail(self) -> QHBoxLayout:
"""Sensor, phase, capture source, calibration, validity, record, actions.
The sensor choice is one summary button (the pickers themselves live in
the sensor dialog), and the capture source is the picker the *current
view* needs: a camera id while acquiring, a video file while running
from a file. Neither is a "source mode" combobox to be discovered.
"""
rail = QHBoxLayout()
rail.setSpacing(8)
self.btn_sensor = QPushButton()
self.btn_sensor.setObjectName("ghostButton")
self.btn_sensor.setProperty("compact", "true")
self.btn_sensor.setMinimumHeight(BUTTON_HEIGHT)
self.btn_sensor.clicked.connect(self.sensor_clicked.emit)
self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip")
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
self.source_summary = ShrinkableLabel()
self.source_summary.setObjectName("panelMeta")
self.calibration_label = ShrinkableLabel()
self.calibration_label.setObjectName("panelMeta")
self.data_state = ShrinkableLabel()
self.data_state.setObjectName("panelMeta")
self.data_state.setVisible(False)
self.record_chip = RecordStatusChip(theme=self._theme)
# --- capture source (one of the two groups is visible) -------------
self.source_slot = QWidget()
slot = QHBoxLayout(self.source_slot)
slot.setContentsMargins(0, 0, 0, 0)
slot.setSpacing(6)
self.camera_group = QWidget()
cam_layout = QHBoxLayout(self.camera_group)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(6)
self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setFixedWidth(64)
self.cam_id_spin.setProperty("rail", "true")
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
self.btn_search = QPushButton()
self.btn_search.setProperty("compact", "true")
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
cam_layout.addWidget(self.btn_search)
self.video_group = QWidget()
video_layout = QHBoxLayout(self.video_group)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(6)
self.btn_use_camera = QPushButton()
self.btn_use_camera.setProperty("compact", "true")
self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT)
self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit)
video_layout.addWidget(self.btn_use_camera)
# The replay view's source is a recorded video: one action in the same
# slot the live view uses for its camera. The file itself is named by the
# summary next to it, so the rail reads the same way in both views.
self.replay_group = QWidget()
replay_layout = QHBoxLayout(self.replay_group)
replay_layout.setContentsMargins(0, 0, 0, 0)
replay_layout.setSpacing(6)
self.btn_replay_video = QPushButton()
self.btn_replay_video.setProperty("compact", "true")
self.btn_replay_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_replay_video.clicked.connect(self.replay_video_clicked.emit)
replay_layout.addWidget(self.btn_replay_video)
slot.addWidget(self.camera_group)
slot.addWidget(self.video_group)
slot.addWidget(self.replay_group)
self.btn_record = QPushButton()
self.btn_record.setObjectName("primaryButton")
self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
self.btn_record.setMinimumWidth(96)
self.btn_record.setCheckable(True)
self.btn_record.setEnabled(False)
self.btn_record.toggled.connect(self._on_record_toggled)
self.btn_run = QPushButton()
self.btn_run.setObjectName("primaryButton")
self.btn_run.setMinimumHeight(BUTTON_HEIGHT)
self.btn_run.setMinimumWidth(96)
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit)
self.btn_settings = QPushButton()
self.btn_settings.setObjectName("ghostButton")
self.btn_settings.setProperty("compact", "true")
self.btn_settings.setMinimumHeight(BUTTON_HEIGHT)
self.btn_settings.setCheckable(True)
self.btn_settings.toggled.connect(self._on_settings_toggled)
rail.addWidget(_rail(self.btn_sensor))
rail.addWidget(_separator())
rail.addWidget(self.status_chip)
rail.addWidget(_separator())
rail.addWidget(self.source_slot)
rail.addWidget(self.source_summary)
rail.addWidget(self.calibration_label)
rail.addWidget(self.data_state)
rail.addStretch(1)
rail.addWidget(self.record_chip)
rail.addWidget(_rail(self.btn_record))
rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_reset))
rail.addWidget(_rail(self.btn_settings))
return rail
def _build_settings(self) -> QWidget:
"""Units, record mode and preview.
The capture source is on the rail (see ``_build_rail``) because it is
what the current view is *about*, not a setting to dig for.
"""
self.settings_page = QWidget()
page = QVBoxLayout(self.settings_page)
page.setContentsMargins(0, 2, 0, 0)
page.setSpacing(6)
self.btn_preview = QPushButton()
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
self.btn_preview.setCheckable(True)
self.btn_preview.toggled.connect(self._on_preview_toggled)
# --- units group --------------------------------------------------
self.units_label = QLabel()
self.units_label.setObjectName("fieldLabel")
self.units_combo = QComboBox()
self.units_combo.setFixedWidth(96)
self.units_combo.setMinimumHeight(CONTROL_HEIGHT)
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
# --- record mode group --------------------------------------------
# "What will be recorded" is a per-recording setting, not live state:
# it lives here so the rail's labels stay readable at 1280 in both
# languages (the record *state* is on the rail, next to the button).
self.record_label = QLabel()
self.record_label.setObjectName("fieldLabel")
self.record_mode_combo = QComboBox()
self.record_mode_combo.setMinimumWidth(120)
self.record_mode_combo.setMinimumHeight(CONTROL_HEIGHT)
top_row = QHBoxLayout()
top_row.setSpacing(10)
top_row.addWidget(self.btn_preview)
top_row.addWidget(_separator())
top_row.addWidget(_group(self.units_label, self.units_combo))
top_row.addWidget(_separator())
top_row.addWidget(_group(self.record_label, self.record_mode_combo))
top_row.addStretch(1)
page.addLayout(top_row)
return self.settings_page
# ------------------------------------------------------------------ API
def settings_expanded(self) -> bool:
return bool(self._settings_expanded)
def set_settings_expanded(self, expanded: bool) -> None:
self._settings_expanded = bool(expanded)
self.btn_settings.blockSignals(True)
self.btn_settings.setChecked(self._settings_expanded)
self.btn_settings.blockSignals(False)
self._settings_scroll.setVisible(self._settings_expanded)
self._apply_settings_button_style()
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self.record_chip.set_theme(self._theme)
def set_sensor_summary(self, text: str) -> None:
"""The rail's sensor button text (``BOX · box/sensor.json``)."""
self.btn_sensor.setText(str(text or ""))
self.btn_sensor.setToolTip(t("sensor.dialog_tip"))
def set_source_summary(self, text: str) -> None:
"""Extra detail about the capture source (fps, video/recording name)."""
self.source_summary.setText(str(text or ""))
# The rail may squeeze this label; the tooltip always carries it whole.
self.source_summary.setToolTip(self.source_summary.text())
self.source_summary.setVisible(bool(text))
def set_source_summary_tip(self, text: str) -> None:
"""What the summary cannot fit: the full path, and any replay notes."""
self.source_summary.setToolTip(str(text or ""))
def set_calibration_summary(self, text: str) -> None:
self.calibration_label.setText(str(text or ""))
self.calibration_label.setToolTip(self.calibration_label.text())
self.calibration_label.setVisible(bool(text))
def set_data_state(self, text: str, token: str = "text_secondary") -> None:
"""Show/clear the validity readout, coloured by a theme token."""
text = str(text or "")
self.data_state.setText(text)
self.data_state.setToolTip(text)
self.data_state.setVisible(bool(text))
if text:
self.data_state.setStyleSheet(
f"QLabel#panelMeta {{ color: {color(self._theme, token)};"
" background: transparent; }"
)
def set_record_state(self, *args, **kwargs) -> None:
"""Delegate the record strip to :class:`RecordStatusChip`."""
self.record_chip.set_state(*args, **kwargs)
def source_mode(self) -> str:
"""``camera`` or ``video``: what a run would acquire from."""
return self._source_mode
def set_source_mode(self, mode: str) -> None:
self._source_mode = "video" if str(mode).lower().startswith("v") else "camera"
self._apply_source_mode_ui()
def set_source_view(self, view: str) -> None:
"""Which picker the current view needs (``live`` or ``replay``)."""
wanted = "replay" if str(view) == "replay" else "live"
if wanted == self._source_view:
return
self._source_view = wanted
self._apply_source_mode_ui()
def set_run_role(self, role: str) -> None:
"""What the primary button does in this view: acquire, or play back."""
wanted = "playback" if str(role) == "playback" else "acquire"
if wanted == self._run_role:
return
self._run_role = wanted
# A view switch never carries the other view's running state across.
if self.btn_run.isChecked():
self.btn_run.blockSignals(True)
self.btn_run.setChecked(False)
self.btn_run.blockSignals(False)
self._refresh_run_label()
def run_role(self) -> str:
return self._run_role
def source_view(self) -> str:
return self._source_view
def camera_id(self) -> int:
return int(self.cam_id_spin.value())
def set_camera_id(self, value: int) -> None:
self.cam_id_spin.blockSignals(True)
self.cam_id_spin.setValue(int(value))
self.cam_id_spin.blockSignals(False)
def video_path(self) -> str:
return str(self._video_path or "")
def set_video_path(self, path: str) -> None:
self._video_path = str(path or "").strip()
self._apply_source_mode_ui()
def units(self) -> str:
data = self.units_combo.currentData()
return str(data or "newton")
def set_units(self, units: str) -> None:
wanted = "raw" if str(units).lower().startswith("r") else "newton"
idx = self.units_combo.findData(wanted)
if idx < 0:
idx = 0
self.units_combo.blockSignals(True)
self.units_combo.setCurrentIndex(idx)
self.units_combo.blockSignals(False)
def set_preview_checked(self, checked: bool) -> None:
self.btn_preview.blockSignals(True)
self.btn_preview.setChecked(bool(checked))
self.btn_preview.blockSignals(False)
self.refresh_labels()
def set_run_checked(self, checked: bool) -> None:
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
self.refresh_labels()
if not checked:
self.btn_reset.setEnabled(False)
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
def set_camera_controls_enabled(self, enabled: bool) -> None:
live = self._source_view == "live"
camera = self.source_mode() == "camera"
allowed = bool(enabled) and live
self.btn_search.setEnabled(allowed and camera)
self.cam_id_spin.setEnabled(allowed and camera)
self.btn_use_camera.setEnabled(allowed and not camera)
# The replay's video picker belongs to the replay view, and follows the
# same rule as the camera picker: not while a session is running.
self.btn_replay_video.setEnabled(bool(enabled) and not live)
def set_source_controls_enabled(self, enabled: bool) -> None:
self._source_controls_enabled = bool(enabled)
self.set_camera_controls_enabled(self._source_controls_enabled)
self.btn_preview.setEnabled(
self._source_controls_enabled
and self._source_view == "live"
and self.source_mode() == "camera"
)
def set_run_enabled(self, enabled: bool) -> None:
self._run_available = bool(enabled)
self.btn_run.setEnabled(bool(enabled))
self._apply_run_tooltip()
def set_run_tooltip_reason(self, reason: str) -> None:
"""Explain *why* Run is unavailable (shown in the button tooltip)."""
self._run_tooltip_reason = str(reason or "")
self._apply_run_tooltip()
def set_run_phase(self, phase: str) -> None:
"""Track the session phase so Stop can advertise itself while connecting."""
self._run_phase = str(phase or "idle")
self.refresh_labels()
def active_source(self) -> str | int | None:
if self.source_mode() == "video":
path = self.video_path()
return path if path else None
return self.camera_id()
def refresh_labels(self) -> None:
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
self._refresh_preview_label()
self._refresh_run_label()
# ------------------------------------------------------------- internals
def _populate_units_combo(self) -> None:
current = self.units_combo.currentData()
self.units_combo.blockSignals(True)
self.units_combo.clear()
self.units_combo.addItem("", "newton")
self.units_combo.addItem("", "raw")
if current:
idx = self.units_combo.findData(current)
self.units_combo.setCurrentIndex(max(0, idx))
self.units_combo.blockSignals(False)
def _populate_record_modes(self) -> None:
current = self.record_mode_combo.currentData()
self.record_mode_combo.blockSignals(True)
self.record_mode_combo.clear()
for value, _key in RECORD_MODES:
self.record_mode_combo.addItem("", value)
if current:
idx = self.record_mode_combo.findData(current)
self.record_mode_combo.setCurrentIndex(max(0, idx))
self.record_mode_combo.blockSignals(False)
def record_mode(self) -> str:
return str(self.record_mode_combo.currentData() or "video_data")
def set_record_enabled(self, enabled: bool) -> None:
"""Enable the record control.
While a recording is running this must stay enabled — it is the only
way to stop it.
"""
self.btn_record.setEnabled(bool(enabled))
self.record_mode_combo.setEnabled(bool(enabled) and not self.btn_record.isChecked())
def set_record_checked(self, checked: bool) -> None:
self.btn_record.blockSignals(True)
self.btn_record.setChecked(bool(checked))
self.btn_record.blockSignals(False)
self._refresh_record_label()
def set_record_status(self, text: str) -> None:
"""Publish where the recording is being written (shown in the tooltip)."""
self.record_chip.set_directory(str(text or ""))
def _on_record_toggled(self, checked: bool) -> None:
self._refresh_record_label()
self.record_mode_combo.setEnabled(not checked)
self.record_toggled.emit(bool(checked))
def _on_settings_toggled(self, checked: bool) -> None:
self._settings_expanded = bool(checked)
self._settings_scroll.setVisible(self._settings_expanded)
self._apply_settings_button_style()
self.settings_toggled.emit(self._settings_expanded)
def _apply_settings_button_style(self) -> None:
self.btn_settings.setProperty(
"settingsOpen", "true" if self._settings_expanded else "false"
)
self.btn_settings.style().unpolish(self.btn_settings)
self.btn_settings.style().polish(self.btn_settings)
def _refresh_record_label(self) -> None:
if self.btn_record.isChecked():
self.btn_record.setText(t("record.stop_label"))
else:
self.btn_record.setText(t("record.start_label"))
self.btn_record.setToolTip(
t("record.stop_tip") if self.btn_record.isChecked() else t("record.start_tip")
)
self._refresh_emphasis()
def _apply_source_mode_ui(self) -> None:
"""Show the picker the current view needs, and nothing else.
Live + camera: the camera id and its search button. Live + video: a
one-click way back to the camera (that source is the recompute pass's).
Replay: the recorded video, which is the one difference between the two
views' rails.
"""
live = self._source_view == "live"
video = self.source_mode() == "video"
self.camera_group.setVisible(live and not video)
self.video_group.setVisible(live and video)
self.replay_group.setVisible(not live)
self.source_slot.setVisible(True)
self.set_source_controls_enabled(self._source_controls_enabled)
if video and self.btn_preview.isChecked():
self.set_preview_checked(False)
self._refresh_source_tooltips()
def _refresh_source_tooltips(self) -> None:
path = self.video_path()
self.source_summary.setToolTip(path or self.source_summary.text())
self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip"))
self.btn_replay_video.setToolTip(t("toolbar.choose_video_tip"))
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
self.units_changed.emit(self.units())
def _refresh_preview_label(self) -> None:
if self.btn_preview.isChecked():
self.btn_preview.setText(t("toolbar.preview_stop"))
else:
self.btn_preview.setText(t("toolbar.preview"))
def _refresh_run_label(self) -> None:
if self._run_role == "playback":
# The primary action of the replay view is playing the recording —
# the same button, doing what this view is for.
self.btn_run.setText(
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
)
self._refresh_emphasis()
self._apply_run_tooltip()
return
if self.btn_run.isChecked():
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
if self._run_phase == "connecting":
self.btn_run.setText(t("toolbar.cancel_label"))
else:
self.btn_run.setText(t("toolbar.stop_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self._refresh_emphasis()
self._apply_run_tooltip()
def _refresh_emphasis(self) -> None:
"""Exactly one *filled* button at a time.
The complaint this rule answers is a red Stop dominating a top area in
which nothing was happening. Stop stays red and one click away, but only
the action of the moment is filled:
* not recording yet → the record action is the filled primary;
* recording → stopping the recording is the filled danger;
* connecting → cancel is the filled danger (it is the way out of a hang).
"""
recording = self.btn_record.isChecked()
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
if self._run_role == "playback":
# The transport is the replay view's own action, not a session stop:
# filled while it plays, an outline when idle, and never red.
self.btn_record.setObjectName(
"dangerButton" if recording else "primaryButton"
)
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
elif recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("dangerOutline")
else:
self.btn_record.setObjectName("primaryButton")
if running and self._run_phase != "connecting":
self.btn_run.setObjectName("dangerOutline")
elif running:
self.btn_run.setObjectName("dangerButton")
else:
self.btn_run.setObjectName("primaryButton")
for button in (self.btn_record, self.btn_run):
button.style().unpolish(button)
button.style().polish(button)
def _apply_run_tooltip(self) -> None:
if self._run_role == "playback":
key = "toolbar.playback_pause_tip" if self.btn_run.isChecked() else "toolbar.playback_tip"
if not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
else:
self.btn_run.setToolTip(t(key))
return
if self.btn_run.isChecked():
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_run.setToolTip(t("toolbar.stop"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
self.btn_run.setToolTip(t("toolbar.run_unavailable"))
else:
tip = t("toolbar.run")
if self._run_tooltip_reason:
tip = f"{tip} — {self._run_tooltip_reason}"
self.btn_run.setToolTip(tip)
def retranslate(self) -> None:
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_search.setText(t("toolbar.search_cameras"))
self.btn_use_camera.setText(t("toolbar.use_camera"))
self.btn_replay_video.setText(t("toolbar.choose_video"))
self.refresh_labels()
self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))
self._refresh_source_tooltips()
for index, (value, key) in enumerate(RECORD_MODES):
if index < self.record_mode_combo.count():
self.record_mode_combo.setItemText(index, t(key))
self.record_mode_combo.setToolTip(t("record.mode_tip"))
self.btn_settings.setText(t("toolbar.settings"))
self.btn_settings.setToolTip(t("toolbar.settings_tip"))
self.record_chip.retranslate()
self._refresh_record_label()
+79
View File
@@ -0,0 +1,79 @@
"""Shared fixtures for the studio app UI tests.
The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the
whole session (Qt forbids creating more than one).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
# truth between the pytest suite and the dependency-free checker.
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
@pytest.fixture(scope="session")
def qt_app():
from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def make_window(qt_app, tmp_path, monkeypatch):
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared import paths as paths_mod
from orisys_studio.app_window import StudioMainWindow
prefs_file = tmp_path / "preference.json"
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
default_prefs = {
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
created = []
def _make(**overrides):
data = dict(default_prefs)
data.update(overrides)
window = StudioMainWindow(prefs=data)
created.append(window)
return window
yield _make
for window in created:
try:
window.close()
except Exception:
pass
def visible_descendants(widget):
"""Yield every visible descendant widget of ``widget``."""
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
+119
View File
@@ -0,0 +1,119 @@
"""Static checks: translation keys and preference defaults stay in sync."""
from __future__ import annotations
import json
import re
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = APP_ROOT / "src"
LOCALES_DIR = APP_ROOT / "config" / "locales"
DEFAULTS_JSON = APP_ROOT / "config" / "preferences.default.json"
REPO_ROOT = APP_ROOT.parents[1]
STORE_PY = REPO_ROOT / "shared" / "src" / "orisys_app_shared" / "prefs" / "store.py"
_T_CALL = re.compile(r"""\bt\(\s*["']([A-Za-z0-9_.]+)["']""")
_DEFAULT_BLOCK = re.compile(r"_DEFAULTS[^=]*=\s*\{(?P<body>.*?)\n\}", re.DOTALL)
_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""")
def _load_locale(name: str) -> dict:
return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8"))
def _flatten(data: dict, prefix: str = "") -> set[str]:
keys: set[str] = set()
for key, value in data.items():
dotted = f"{prefix}{key}"
if isinstance(value, dict):
keys |= _flatten(value, f"{dotted}.")
else:
keys.add(dotted)
return keys
def _referenced_keys() -> set[str]:
found: set[str] = set()
for path in sorted(SRC_DIR.rglob("*.py")):
found |= set(_T_CALL.findall(path.read_text(encoding="utf-8")))
return found
def test_locales_parity_and_used_keys():
zh = _flatten(_load_locale("zh_CN"))
en = _flatten(_load_locale("en_US"))
assert zh == en, {
"zh_only": sorted(zh - en),
"en_only": sorted(en - zh),
}
referenced = _referenced_keys()
assert referenced, "no t() calls found — the source scan is broken"
missing = sorted(key for key in referenced if key not in zh)
assert not missing, f"t() keys missing from locale files: {missing}"
def test_preference_defaults_match_store_defaults():
json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8")))
source = STORE_PY.read_text(encoding="utf-8")
match = _DEFAULT_BLOCK.search(source)
assert match, "could not locate _DEFAULTS in prefs/store.py"
store_keys = set(_KEY.findall(match.group("body")))
assert json_keys == store_keys, {
"json_only": sorted(json_keys - store_keys),
"store_only": sorted(store_keys - json_keys),
}
def test_locale_files_are_valid_utf8_json():
for name in ("zh_CN", "en_US"):
data = _load_locale(name)
assert isinstance(data, dict)
assert data.get("app", {}).get("title")
#: Sequences that only ever appear when UTF-8 text was decoded as a single-byte
#: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip
#: in Windows PowerShell does exactly that and silently mangles every non-ASCII
#: string, which is how the Chinese UI once ended up unreadable.
#:
#: Written as escapes, with no literal examples in the comments, so that this
#: guard cannot itself be corrupted by the same mistake.
_MOJIBAKE = (
"\u00c3",
"\u00c2",
"\u00e2\u20ac",
"\u00e3\u20ac",
"\u00ef\u00bf\u00bd",
)
def test_locale_files_are_utf8_without_bom_and_not_mojibake():
for name in ("zh_CN", "en_US"):
path = LOCALES_DIR / f"{name}.json"
raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
text = raw.decode("utf-8") # raises if it is not valid UTF-8
for sequence in _MOJIBAKE:
assert sequence not in text, (
f"{name}.json contains {sequence!r}: the file was decoded with the "
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
"of a PowerShell Get-Content/Set-Content round trip."
)
def test_chinese_locale_actually_contains_chinese():
"""A corrupted zh_CN.json still parses as JSON, so check the content."""
data = _load_locale("zh_CN")
for key in ("menu/theme_dark", "app/about_title", "chart/mode_follow"):
node = data
for part in key.split("/"):
node = node[part]
assert any("\u4e00" <= char <= "\u9fff" for char in node), (
f"zh_CN.{key} has no Chinese characters: {node!r}"
)
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
"""Ask each camera what it can actually deliver, without the app in the way.
The studio's sensor configs pin the capture at 1920x1080 @ 30 fps (the tile grid
is 5x3 tiles of 384x360, so the size is structural), and nothing downstream can
raise a frame rate the driver never offered. When a session runs at ~15-17 fps,
there are three questions to separate, and this script answers all three:
1. what does the driver *report* for a given request?
2. what does it actually **deliver**? (Measured from 60 reads. On DSHOW the
reported figure is a liar: ``CAP_PROP_FPS`` echoes back whatever was set, so a
camera that advertises 30 and delivers 15 reports "30.0".)
3. does the delivered rate follow the request? The sweep at the end asks for
60/30/20/15/10 fps at one format. A rate that *halves* with the request means
the request path is wrong; a rate that sticks at the same number whatever is
asked means that number is the hardware's ceiling.
Run it with the venv interpreter, with the app closed (two processes cannot own
the same camera)::
python apps/studio/tools/camera_probe.py # every camera
python apps/studio/tools/camera_probe.py --index 0 # just one
python apps/studio/tools/camera_probe.py --samples 120 # longer measurement
"""
from __future__ import annotations
import argparse
import sys
import time
import cv2
#: What the sensor bundles ask for, and the alternatives worth trying when the
#: delivered rate falls short of the requested one.
REQUESTS: tuple[tuple[str, int, int, float], ...] = (
("MJPG", 1920, 1080, 30.0),
("MJPG", 1920, 1080, 0.0), # "whatever the driver prefers at this size"
("MJPG", 1280, 720, 30.0),
("YUYV", 1920, 1080, 30.0),
("YUYV", 1280, 720, 30.0),
)
BACKENDS: tuple[tuple[str, int], ...] = (
("DSHOW", cv2.CAP_DSHOW),
("MSMF", cv2.CAP_MSMF),
)
#: Frame rates asked for at one fixed format, to see whether the delivered rate
#: follows the request or ignores it.
SWEEP_RATES: tuple[float, ...] = (60.0, 30.0, 20.0, 15.0, 10.0)
#: Sizes asked for at one fixed rate. This is what separates the two remaining
#: explanations for a low rate: "the link cannot carry the pixels" (smaller
#: frames go faster) from "this camera is a 15 fps device" (the rate does not
#: move at all).
SWEEP_SIZES: tuple[tuple[int, int], ...] = (
(1920, 1080),
(1280, 720),
(960, 540),
(848, 480),
(640, 480),
)
SAMPLES = 60
#: A request is "missed" when the measured rate is below this fraction of it.
TOLERANCE = 0.9
def _fourcc_code(text: str) -> int:
return cv2.VideoWriter_fourcc(*text)
def _decode(code: float) -> str:
value = int(code)
if value <= 0:
return "-"
letters = "".join(chr((value >> (8 * index)) & 0xFF) for index in range(4))
return letters if letters.isprintable() else f"0x{value:08x}"
def probe(
index: int,
backend_name: str,
backend: int,
request: tuple[str, int, int, float],
*,
samples: int = SAMPLES,
) -> dict | None:
"""Open, ask for one format, and measure what actually comes out."""
fourcc, width, height, fps = request
cap = cv2.VideoCapture(index, backend)
if not cap.isOpened():
cap.release()
return None
try:
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width))
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height))
if fps > 0:
cap.set(cv2.CAP_PROP_FPS, float(fps))
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
for _ in range(5): # let the format settle before believing anything
cap.grab()
started = time.perf_counter()
taken = 0
for _ in range(samples):
ok, frame = cap.read()
if not ok or frame is None:
break
taken += 1
span = time.perf_counter() - started
got_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
got_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
reported = cap.get(cv2.CAP_PROP_FPS)
got_cc = _decode(cap.get(cv2.CAP_PROP_FOURCC))
finally:
cap.release()
return {
"asked": (fourcc, width, height, fps),
"backend": backend_name,
"fourcc": got_cc,
"width": got_w,
"height": got_h,
"reported_fps": reported,
"measured_fps": (taken / span) if span > 0 else 0.0,
"taken": taken,
"samples": samples,
}
def format_row(result: dict) -> str:
fourcc, width, height, fps = result["asked"]
asked = f"{fourcc} {width}x{height}" + (f"@{fps:g}" if fps else "@auto")
measured = result["measured_fps"]
missed = ""
if fps > 0 and measured < fps * TOLERANCE:
missed = " <-- misses the request"
return (
f" {asked:<20} -> {result['fourcc']} {result['width']}x{result['height']} "
f"reported {result['reported_fps']:5.1f} measured {measured:5.1f} fps "
f"({result['taken']}/{result['samples']}) [{result['backend']}]{missed}"
)
def sweep(index: int, backend_name: str, backend: int, size: tuple[int, int],
*, samples: int = SAMPLES) -> list[dict]:
"""Ask for a range of rates at one format: does the delivery follow?"""
width, height = size
rows = []
for rate in SWEEP_RATES:
result = probe(
index, backend_name, backend, ("MJPG", width, height, rate),
samples=samples,
)
if result is not None:
rows.append(result)
return rows
def size_sweep(index: int, backend_name: str, backend: int, fps: float = 30.0,
*, samples: int = SAMPLES) -> list[dict]:
"""Ask for one rate at shrinking sizes: bandwidth, or a fixed ceiling?
A link that cannot carry the pixels gets faster as the frames get smaller. A
camera whose own rate is 15 fps delivers 15 whatever it is asked for.
"""
rows = []
for width, height in SWEEP_SIZES:
result = probe(
index, backend_name, backend, ("MJPG", width, height, fps),
samples=samples,
)
if result is not None:
rows.append(result)
return rows
def check_format(
index: int,
*,
request: tuple[str, int, int, float] = REQUESTS[0],
samples: int = 120,
) -> int:
"""Acceptance mode: does this camera deliver the bundle's format?
One line, one exit code, for going through units in turn. The request is the
one the sensor bundles make (MJPG 1920x1080@30); the verdict is the *measured*
rate, because the reported one is an echo.
"""
fourcc, width, height, fps = request
for backend_name, backend in BACKENDS:
result = probe(index, backend_name, backend, request, samples=samples)
if result is None:
continue
measured = result["measured_fps"]
asked = f"{fourcc} {width}x{height}@{fps:g}"
if measured >= fps * TOLERANCE:
print(f"PASS camera {index}: {asked} -> {measured:.1f} fps "
f"[{backend_name}, {result['fourcc']}]")
return 0
print(
f"FAIL camera {index}: {asked} -> {measured:.1f} fps "
f"(reported {result['reported_fps']:.1f}, {result['fourcc']}, "
f"{backend_name})"
)
return 1
print(f"No camera answered on index {index} (is the app closed?).")
return 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--index", type=int, default=None, help="camera index to probe")
parser.add_argument("--max-index", type=int, default=4, help="highest index to try")
parser.add_argument("--samples", type=int, default=SAMPLES, help="frames per row")
parser.add_argument(
"--check",
action="store_true",
help="acceptance mode: measure the bundle's format once, exit 0 when it "
"delivers it (use this to go through units)",
)
args = parser.parse_args(argv)
if args.check:
return check_format(args.index if args.index is not None else 0,
samples=args.samples)
indices = [args.index] if args.index is not None else list(range(args.max_index + 1))
found = 0
for index in indices:
header = False
first_size: tuple[int, int] | None = None
first_backend: tuple[str, int] | None = None
for backend_name, backend in BACKENDS:
for request in REQUESTS:
result = probe(index, backend_name, backend, request, samples=args.samples)
if result is None:
continue
if not header:
print(f"\ncamera {index}:")
header = True
print(format_row(result))
if first_size is None and request[0] == "MJPG" and request[3] > 0:
first_size = (request[1], request[2])
first_backend = (backend_name, backend)
if header and first_size is not None and first_backend is not None:
print(
f"\n sweep at MJPG {first_size[0]}x{first_size[1]} "
f"[{first_backend[0]}] -- does the delivered rate follow the request?"
)
rows = sweep(index, first_backend[0], first_backend[1], first_size,
samples=args.samples)
for result in rows:
asked = result["asked"][3]
measured = result["measured_fps"]
verdict = ""
if asked > 0 and measured > 0:
ratio = measured / asked
if ratio < 0.35:
verdict = f" -> {ratio:.2f}x the request"
elif ratio > 0.9:
verdict = " -> follows the request"
print(
f" asked {asked:5.1f} fps -> measured {measured:5.1f} fps"
f"{verdict}"
)
rates = [r["measured_fps"] for r in rows if r["measured_fps"] > 0]
if rates:
print(
f" ceiling seen here: {max(rates):.1f} fps "
f"(a ceiling that does not move with the request is the "
f"hardware's, not the request's)"
)
print(
f"\n sizes at MJPG@{30:g} fps [{first_backend[0]}] -- bandwidth, "
f"or a fixed ceiling?"
)
for result in size_sweep(
index, first_backend[0], first_backend[1], samples=args.samples
):
width, height = result["asked"][1], result["asked"][2]
print(
f" {width}x{height:<5} -> measured {result['measured_fps']:5.1f} fps"
)
print(
" smaller frames going faster means the link is the limit; the "
"same number at every size means the camera is"
)
if header:
found += 1
if not found:
print("No camera answered on indices 0..%d." % args.max_index)
print(
"That is expected on a machine with no camera attached. On the machine "
"with the sensor, run it with the app closed: two processes cannot own "
"the same camera."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Render the studio UI offscreen to PNG files for visual review.
The tool never opens a camera and never starts acquisition: it builds the real
main window, optionally injects deterministic demo data, then grabs the window.
Examples
--------
python tools/ui_snapshot.py --theme dark --out out_dark.png
python tools/ui_snapshot.py --theme light --sensor finger \
--panels cal,3d --demo --size 1600x900 --out out_light.png
python tools/ui_snapshot.py --demo --scene recording --out out_recording.png
"""
from __future__ import annotations
import argparse
import math
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = APP_ROOT.parents[1]
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src", APP_ROOT / "tools"):
if _path.is_dir() and str(_path) not in sys.path:
sys.path.insert(0, str(_path))
# Silent platform must be selected before QApplication exists.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(fonts)
os.chdir(APP_ROOT)
def _replay_data(window, app) -> Path:
"""Synthesize one short recording (with visible frames) and open it."""
import numpy as np
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from ui_check import _fake_packet
root = APP_ROOT / "outputs" / "_snapshot_replay"
if root.exists():
import shutil
shutil.rmtree(root, ignore_errors=True)
root.mkdir(parents=True, exist_ok=True)
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0))
recording = Path(
recorder.start(
root=root,
session_id=1,
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(root / "sensor.json"),
"config_sha256": "snapshot",
},
"versions": {"sdk": "snapshot"},
"depth": {"stored": False, "replay": "recompute"},
},
)
)
# A recognisable frame: a horizontal gradient with a bright bar, so the
# replay's "recorded image" view is reviewable instead of black.
height, width = 108, 192
frame = np.zeros((height, width, 3), np.uint8)
frame[:, :, 1] = np.linspace(20, 200, width, dtype=np.uint8)[None, :]
frame[height // 3 : 2 * height // 3, :, 2] = 180
for index in range(150):
recorder.on_packet(_fake_packet(index, frame=frame))
recorder.stop()
return recording
def _run_scene(window, app, scene: str):
"""Drive the window into a scene that would otherwise need hardware.
``recording`` mocks the record channel's stats through the same single seam
the app reads them from (``_record_stats``), so nothing is written to disk
and no device is opened: the render is of the real widgets, not of a mockup.
``replay`` writes one short synthetic recording and opens it in the replay
view, which is the only way to review the progress bar without a session.
"""
from ui_check import _fake_packet, _StuckWorker
if scene == "replay":
window.brand_bar.btn_playback.click()
for _ in range(4):
app.processEvents()
recording = _replay_data(window, app)
window._load_recording(recording)
# Park the playhead where the bar and the charts show a real position.
window.replay.set_position(window.replay.duration_s() * 0.4)
window.arrow_panel.set_view_mode("recorded")
for _ in range(6):
app.processEvents()
return
stuck = _StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
for _ in range(4):
app.processEvents()
if scene != "recording":
return
# Real packets so the phase reaches ``running`` and the rail holds real
# values; the recording itself is the only mocked part.
for index in range(300):
stuck.emit(_fake_packet(index))
window._on_run_tick()
for _ in range(4):
app.processEvents()
window._run_timer.stop()
from orisys_app_shared.record.experiment import RecordStats
from orisys_studio.ui import record_status
directory = "20240101T101500Z_session0001_a1b2c3d4e5f6"
window.toolbar.set_record_checked(True)
window.toolbar.set_record_status(directory)
window._record_stats = lambda: (
record_status.RECORDING,
RecordStats(
duration_s=12.4,
frames=372,
segments=1,
dropped_data=3,
bytes_written=48_000_000,
),
directory,
)
window._refresh_record_state()
# A visible window inside the session, like the mock's 60-65 s of 0-65 s.
first, last = window.session_store.extent()
if last > first:
window.force_plots.set_window(max(first, last - 6.0), last)
for _ in range(4):
app.processEvents()
def _demo_data(window) -> None:
"""Fill the plots, the calibration table and the arrow canvas."""
from orisys_app_shared.force.session import SessionSample, SessionStore
plots = window.force_plots
# The panel reads a *selection* out of the attached source, so the demo needs
# a real session store behind it (a store-less panel can only follow live).
store = SessionStore()
plots.set_store(store)
for i in range(240):
t = i / 30.0
fn = 6.0 + 5.0 * math.sin(t * 1.4)
fx = 4.0 * math.sin(t * 2.1 + 0.4)
fy = -3.0 + 2.5 * math.cos(t * 1.1)
store.append(
SessionSample(
frame_id=i,
t_rel_s=t,
fn_raw=fn * 7.9 + 9.0,
fx_raw=fx * 7.9,
fy_raw=fy * 7.9,
fn_n=fn,
fx_n=fx,
fy_n=fy,
calibrated_axes=("fn", "fx", "fy"),
)
)
plots.push(t, fn, fx, fy, unit="N")
# The overview reads the *source*, and ``push`` only rebuilds it once a second
# (the demo fills a whole session in a few milliseconds), so build it once here.
plots._overview_dirty = True
plots._last_overview_at = 0.0
plots._sync_overview()
cal = window.cal_panel
cal.set_running(True)
cal.push_raw(9.81, -0.42, 0.10)
for weight, raw, std in ((1.0, 9.72, 0.031), (2.0, 19.51, 0.044), (5.0, 48.90, 0.061)):
from orisys_app_shared.force.calibration_io import CalSample
cal._store.upsert("fn", CalSample(weight_n=weight, raw_mean=raw, n=180, std=std))
cal._refresh_table()
# Synthetic outward flow so the branded canvas and flow ramp are reviewable.
import numpy as np
from orisys_app_shared.acquisition.arrows import (
draw_arrow_display,
flow_colormap,
hex_to_bgr,
)
from orisys_app_shared.theme import color
from orisys_studio.app_window import _bgr_to_qimage
size = 320
grid_y, grid_x = np.mgrid[0:size, 0:size].astype(np.float32)
centre = size / 2.0
flow = np.zeros((size, size, 2), np.float32)
flow[..., 0] = (grid_x - centre) * 0.10
flow[..., 1] = (grid_y - centre) * 0.10
blank = np.zeros((size, size, 3), np.uint8)
rendered = draw_arrow_display(
blank,
flow,
background="pure_color",
canvas_bgr=hex_to_bgr(color(window._theme, "arrow_canvas", "#0B1830")),
draw_style="finger" if window.sensor_picker.sensor_type() == "finger" else "box",
colormap=flow_colormap(window._theme),
)
window.arrow_panel.show_frame(_bgr_to_qimage(rendered))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", choices=("dark", "light"), default="dark")
parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN")
parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box")
parser.add_argument(
"--panels",
default="",
help="comma list of optional panels to show: cal,3d",
)
parser.add_argument("--size", default="1440x880", help="WxH window size")
parser.add_argument("--view", default="arrows_pure")
parser.add_argument(
"--preset",
choices=("custom", "live", "replay", "calibration"),
default="custom",
help="top-level view to render (the same layouts the brand rail switches)",
)
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument(
"--scene",
choices=("idle", "connecting", "recording", "replay"),
default="idle",
help=(
"connecting renders the Stop/cancel state of a hung device open; "
"recording renders the record strip of a running recording "
"(mocked stats: no file is written, no device is opened); "
"replay writes one short synthetic recording and opens it in the "
"replay view so the progress bar can be reviewed"
),
)
parser.add_argument(
"--select",
default="",
help=(
"A,B — browse the live demo session inside that window so the "
"selection readout (cards = peak / mean · σ) can be reviewed"
),
)
parser.add_argument("--out", required=True, help="output PNG path")
args = parser.parse_args()
try:
width, height = (int(part) for part in args.size.lower().split("x", 1))
except ValueError:
parser.error("--size must look like 1440x880")
selection = None
if args.select:
try:
start, end = (float(part) for part in args.select.split(",", 1))
except ValueError:
parser.error("--select must look like 2,6")
selection = (start, end)
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_studio.app_window import StudioMainWindow
app = QApplication(sys.argv)
set_locale(args.lang)
app.setStyleSheet(build_qss(args.theme))
prefs = {
"language": args.lang,
"theme": args.theme,
"sensor_type": args.sensor,
"show_cal_panel": "cal" in panels,
"show_finger_3d": "3d" in panels,
"show_fps": True,
"image_view_mode": args.view,
"view_preset": args.preset,
}
window = StudioMainWindow(prefs=prefs)
window.resize(width, height)
window.show()
app.processEvents()
if args.demo and args.scene == "replay":
pass # the recording is the demo data
if args.scene == "connecting":
# A device open that never returns: the state the Stop button must
# survive. ``ui_check._StuckWorker`` drives the same window state.
_run_scene(window, app, "connecting")
if args.scene == "recording":
_run_scene(window, app, "recording")
if args.scene == "replay":
_run_scene(window, app, "replay")
if args.demo and args.scene != "replay":
_demo_data(window)
for _ in range(4):
app.processEvents()
if selection is not None:
# A selection is a browse window narrower than the session: the value
# cards state its peak and mean · σ, the strip only its size.
window.force_plots.set_window(*selection)
for _ in range(4):
app.processEvents()
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
ok = window.grab().save(str(out))
print(f"{'saved' if ok else 'FAILED'} {out} ({width}x{height}, theme={args.theme})")
window.close()
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+14 -14
View File
@@ -1,19 +1,19 @@
# Build
Windows packaging for the customer desktop app.
Windows packaging for the studio desktop app.
## Layout
```text
build/
customer/
studio/
entry.py # frozen entry (no source chdir)
orisys-customer.spec # PyInstaller onedir spec
orisys-studio.spec # PyInstaller onedir spec
build.ps1 # create/reuse .venv-build and build
_pyinstaller/ # PyInstaller workpath (gitignored)
dist/
OrisysCustomer/
OrisysCustomer.exe
OrisysStudio/
OrisysStudio.exe
config/ # lean locales + sensors (no finger visualization/)
_internal/ # Python, PyQt5, opencv-headless, orisys, …
```
@@ -26,7 +26,7 @@ dist/
## Build
From `build\customer` (recommended):
From `build\studio` (recommended):
```bat
build.bat
@@ -37,7 +37,7 @@ build.bat -SkipInstall
Or from repo root:
```powershell
powershell -ExecutionPolicy Bypass -File .\build\customer\build.ps1
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
```
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
@@ -49,7 +49,7 @@ Flags:
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
Output: `dist\OrisysCustomer\OrisysCustomer.exe`
Output: `dist\OrisysStudio\OrisysStudio.exe`
## Dependency notes
@@ -59,22 +59,22 @@ Output: `dist\OrisysCustomer\OrisysCustomer.exe`
## Size / startup
- **onedir** (not onefile): faster cold start; ship the whole `OrisysCustomer` folder.
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved).
- Customer shell defers OpenCV / acquisition imports until Preview or Run.
- Studio shell defers OpenCV / acquisition imports until Preview or Run.
Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle.
## Verification
1. Launch `OrisysCustomer.exe` on the build machine (and ideally a clean PC without Python).
1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python).
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE.
3. Camera preview + box/mini/finger Run (video) with image modes and force plots.
4. Confirm no `open3d*` files under `dist\OrisysCustomer`.
4. Confirm no `open3d*` files under `dist\OrisysStudio`.
## Editable run (unchanged)
```powershell
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
python D:\repo\orisys-app\apps\customer\run.py
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src"
python D:\repo\orisys-app\apps\studio\run.py
```
-8
View File
@@ -1,8 +0,0 @@
"""Frozen entry for Orisys Customer (no source-tree chdir)."""
from __future__ import annotations
from orisys_customer.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,6 +1,6 @@
@echo off
setlocal EnableExtensions
REM Build Orisys Customer EXE. Works from any cwd; forwards args to build.ps1
REM Build Orisys Studio EXE. Works from any cwd; forwards args to build.ps1
REM Examples:
REM build.bat
REM build.bat -Clean
@@ -1,7 +1,7 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Build Orisys Customer Windows onedir EXE with PyInstaller.
Build Orisys Studio Windows onedir EXE with PyInstaller.
.PARAMETER Clean
Pass --clean to PyInstaller (slower; use when deps/spec change).
@@ -19,7 +19,7 @@ $ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$VenvDir = Join-Path $RepoRoot ".venv-build"
$Python = Join-Path $VenvDir "Scripts\python.exe"
$Spec = Join-Path $PSScriptRoot "orisys-customer.spec"
$Spec = Join-Path $PSScriptRoot "orisys-studio.spec"
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
$DistPath = Join-Path $RepoRoot "dist"
@@ -31,11 +31,11 @@ if (-not (Test-Path $Python)) {
}
if (-not $SkipInstall) {
Write-Host "Installing build dependencies (editable shared + customer + orisys + pyinstaller)…"
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…"
& $Python -m pip install --upgrade pip
& $Python -m pip install "pyinstaller>=6.0"
& $Python -m pip install -e (Join-Path $RepoRoot "shared")
& $Python -m pip install -e (Join-Path $RepoRoot "apps\customer")
& $Python -m pip install -e (Join-Path $RepoRoot "apps\studio")
# orisys may already be installed; ensure present without [gui]/open3d
& $Python -m pip install "orisys>=0.5"
@@ -73,12 +73,12 @@ if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$OutDir = Join-Path $DistPath "OrisysCustomer"
$OutDir = Join-Path $DistPath "OrisysStudio"
Write-Host ""
Write-Host "Build complete: $OutDir"
# Lean config beside EXE (exclude finger visualization assets)
$ConfigSrc = Join-Path $RepoRoot "apps\customer\config"
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
$ConfigDst = Join-Path $OutDir "config"
if (Test-Path $ConfigSrc) {
Write-Host "Copying lean config to $ConfigDst …"
@@ -96,7 +96,7 @@ if (Test-Path $ConfigSrc) {
}
}
Write-Host "Launch: $(Join-Path $OutDir 'OrisysCustomer.exe')"
Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')"
# Size summary
if (Test-Path $OutDir) {
+8
View File
@@ -0,0 +1,8 @@
"""Frozen entry for Orisys Studio (no source-tree chdir)."""
from __future__ import annotations
from orisys_studio.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,5 +1,5 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller onedir spec for Orisys Customer (Windows)."""
"""PyInstaller onedir spec for Orisys Studio (Windows)."""
from __future__ import annotations
@@ -57,7 +57,7 @@ icon = str(icon_path) if icon_path.is_file() else None
a = Analysis(
[str(ENTRY)],
pathex=[
str(REPO_ROOT / "apps" / "customer" / "src"),
str(REPO_ROOT / "apps" / "studio" / "src"),
str(REPO_ROOT / "shared" / "src"),
],
binaries=binaries,
@@ -105,7 +105,7 @@ exe = EXE(
a.scripts,
[],
exclude_binaries=True,
name="OrisysCustomer",
name="OrisysStudio",
debug=False,
bootloader_ignore_signals=False,
strip=False,
@@ -127,5 +127,5 @@ coll = COLLECT(
strip=False,
upx=False,
upx_exclude=[],
name="OrisysCustomer",
name="OrisysStudio",
)
+367
View File
@@ -0,0 +1,367 @@
"""Studio 3D v1 design contracts, 2026-09-14.
Specification only: split into the proposed modules when implementing.
No rendering, asset loading, decryption or SDK changes are implemented here.
All published NumPy arrays must be C-contiguous and read-only (frozen dataclasses
alone do not freeze ndarray contents). Shapes are contractual, not type-checked
by NDArray. See studio-3d-design.md for threading and validation requirements.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar
from concurrent.futures import Executor
import numpy as np
from numpy.typing import NDArray
from PyQt5.QtCore import QEvent, pyqtBoundSignal
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
from orisys_app_shared.acquisition.models import SdkPacket
from orisys.finger.runtime import FingerRuntime
F32: TypeAlias = NDArray[np.float32]
F64: TypeAlias = NDArray[np.float64]
I32: TypeAlias = NDArray[np.int32]
U32: TypeAlias = NDArray[np.uint32]
Mask: TypeAlias = NDArray[np.bool_]
Role: TypeAlias = Literal['sensing', 'housing', 'reference']
Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only']
Profile: TypeAlias = Literal['desktop33', 'gles30']
OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid']
SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable']
@dataclass(frozen=True)
class Diagnostic:
code: str # Stable machine-readable code, e.g. geometry_mismatch.
severity: Literal['info', 'warning', 'error']
message: str # Human-readable, never contains keys or decrypted asset data.
details: Mapping[str, str]
class ThreeDError(Exception):
"""Boundary exception; GUI converts to diagnostic and keeps 2D operational."""
diagnostic: Diagnostic
def __init__(self, diagnostic: Diagnostic) -> None: ...
@dataclass(frozen=True)
class FrameIdentity:
source_id: str # Live stream UUID or recording UUID; not just a filename.
session_id: int
frame_id: int
t_rel_s: float # Acquisition-relative monotonic time, never wall-clock for ordering.
generation: int # Increment on activation/source/geometry change.
request_id: int # Replay cursor request; 0 for ordinary live frames.
@dataclass(frozen=True)
class FieldDescriptor:
quantity: str # Examples: flow_magnitude, signed_normal_displacement, depth_response.
unit: str # px, m, or explicit algorithm-response unit; never inferred from shape.
quality: Quality
signed: bool
calibration_id: str | None
@dataclass(frozen=True)
class SurfaceFieldFrame:
identity: FrameIdentity
geometry_id: str
field_domain_id: str # Includes geometry, UV shape, crop/scale/flip convention.
scalar: F32 # [H,W]; measured/displayed quantity, not RGB.
scalar_descriptor: FieldDescriptor
valid: Mask # [H,W]; false or non-finite scalar means unavailable, not zero.
displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain.
displacement_quality: Quality | None
diagnostic: Diagnostic | None
@dataclass(frozen=True)
class Material:
base_color_linear: tuple[float, float, float, float]
shading: Literal['unlit_scalar', 'diffuse']
double_sided: bool
@dataclass(frozen=True)
class MeshPart:
part_id: str
role: Role
positions_sensor_m: F32 # [N,3], baked coordinates.
normals_sensor: F32 # [N,3], finite unit normals.
triangles: U32 # [M,3], in [0,N).
material: Material
lod_id: str
@dataclass(frozen=True)
class SurfaceBinding:
part_id: str # The sensing part only.
geometry_id: str
field_domain_id: str
shape_hw: tuple[int, int]
sample_indices: I32 # [N,4], row-major y*W+x; unused index=-1 with weight=0.
sample_weights: F32 # [N,4], nonnegative, used weights sum to one.
@dataclass(frozen=True)
class CameraPreset:
name: str
eye_sensor_m: tuple[float, float, float]
target_sensor_m: tuple[float, float, float]
up_sensor: tuple[float, float, float]
vertical_fov_rad: float
@dataclass(frozen=True)
class ExpectedGeometry:
sensor_model: str
hardware_revision: str
geometry_id: str
field_domain_id: str
shape_hw: tuple[int, int]
@dataclass(frozen=True)
class SceneAssets:
asset_id: str
asset_version: str
asset_hash: str # Digest of canonical manifest/content, not encryption nonce.
geometry_id: str
parts: tuple[MeshPart, ...]
binding: SurfaceBinding # v1 supports exactly one sensing part.
camera_presets: tuple[CameraPreset, ...]
bounds_sensor_m: F32 # [2,3]: min, max.
@dataclass(frozen=True)
class ColorScale:
descriptor: FieldDescriptor
lower: float # Finite; lower < upper, in descriptor.unit.
upper: float
lut_id: str # Approved sequential/diverging LUT.
mode: Literal['fixed', 'automatic']
@dataclass(frozen=True)
class DisplaySettings:
display_gain: float # Finite positive; affects only dynamic positions.
deform_enabled: bool
color_scale: ColorScale
housing_visible: bool
max_fps: int # Display limit; never forwarded as camera/recording FPS.
@dataclass(frozen=True)
class VertexFields:
scalar: F32 # [N], unscaled scientific value.
valid: Mask # [N]; any positive-weight invalid sample invalidates the vertex.
displacement_sensor_m: F32 | None # [N,3].
@dataclass(frozen=True)
class RenderFrame:
identity: FrameIdentity
asset_hash: str
geometry_id: str
sensing_part_id: str
positions_sensor_m: F32 # [N,3], display displacement already applied.
normals_sensor: F32 # [N,3], valid even at vertices excluded by mask.
scalar: F32 # [N], BEFORE display gain.
valid: Mask # [N].
scalar_descriptor: FieldDescriptor
diagnostics: tuple[Diagnostic, ...]
@dataclass(frozen=True)
class CameraMatrices:
view: F32 # [4,4]; p_camera = view @ p_sensor.
projection: F32 # [4,4]; p_clip = projection @ p_camera.
@dataclass(frozen=True)
class GLCapabilities:
profile: Profile
vendor: str
renderer: str
gl_version: str
glsl_version: str
qt_platform: str
software_renderer: bool
max_texture_size: int
diagnostics: tuple[Diagnostic, ...]
@dataclass(frozen=True)
class RenderStats:
displayed_frames: int
superseded_frames: int
compose_ms: float
upload_ms: float # CPU submission time unless a separate GPU timer is used.
paint_ms: float
estimated_gpu_bytes: int
class KeyProvider(Protocol):
def get_content_key(self, key_id: str) -> bytes:
"""Return authorized content key, or raise ThreeDError; never log it."""
...
class AssetLoader(Protocol):
def load(self, path: Path, expected: ExpectedGeometry, keys: KeyProvider | None = None) -> SceneAssets:
"""CPU/IO thread. Authenticate and validate before publishing assets."""
...
def validate(self, assets: SceneAssets, expected: ExpectedGeometry) -> tuple[Diagnostic, ...]: ...
class LegacyFingerImporter(Protocol):
def convert(self, export_dir: Path, map_path: Path, *,
transform_sensor_from_source: F64, meters_per_source_unit: float) -> SceneAssets:
"""Offline. Scale source lengths to meters, then apply a rigid transform.
transform translation is meters; its rotation contains no extra scale.
"""
...
@dataclass(frozen=True)
class CompileOptions:
asset_id: str
version: str
expected: ExpectedGeometry
transform_sensor_from_glb: F64 # [4,4]; GLB lengths are already meters.
max_surface_vertices: int
max_housing_triangles: int
signing_key_reference: str # Build environment reference, not private key bytes.
encryption_key_id: str | None
@dataclass(frozen=True)
class CompileReport:
output: Path
asset_hash: str
diagnostics: tuple[Diagnostic, ...]
part_vertex_counts: Mapping[str, int]
part_triangle_counts: Mapping[str, int]
class SceneCompiler(Protocol):
def compile(self, surface: SceneAssets, housing_glb: Path | None, output: Path,
options: CompileOptions) -> CompileReport: ...
class FingerFrameAdapter(Protocol):
def snapshot(self, packet: SdkPacket, runtime: FingerRuntime,
identity: FrameIdentity) -> SurfaceFieldFrame | None:
"""Acquisition thread only. Copy/own one frame; no GL and no GUI calls."""
...
class BoxFrameAdapter(Protocol):
def snapshot(self, packet: SdkPacket, identity: FrameIdentity,
domain: ExpectedGeometry) -> SurfaceFieldFrame | None: ...
class SurfaceSampler(Protocol):
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
class SceneComposer(Protocol):
def compose(self, assets: SceneAssets, frame: SurfaceFieldFrame,
settings: DisplaySettings) -> RenderFrame:
"""CPU worker. Validate identity/units, sample, deform, update normals."""
...
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
"""Static reference; all measurement validity false, scalar unavailable."""
...
T = TypeVar('T')
class LatestFrameMailbox(Generic[T]):
def __init__(self) -> None: ...
def offer(self, frame: T) -> OfferResult:
"""Thread-safe capacity-one replacement, constant-time under the lock."""
...
def take(self) -> T | None: ...
def clear(self) -> None: ...
@property
def superseded_frames(self) -> int: ...
class OrbitCamera(Protocol):
def fit(self, bounds_sensor_m: F32, aspect: float) -> None: ...
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
def zoom(self, wheel_steps: float) -> None: ...
def set_preset(self, preset: CameraPreset) -> None: ...
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices: ...
def configure_surface_format(profile: Profile) -> None:
"""Before QApplication and before importing a platform-specific GL binding."""
...
class GLDispatch(Protocol):
def bind_current_context(self, context: QOpenGLContext) -> GLCapabilities:
"""GUI thread; Qt owns the current context and native surface."""
...
class GLRenderer(Protocol):
def initialize(self, capabilities: GLCapabilities) -> None: ...
def upload_scene(self, assets: SceneAssets) -> None: ...
def update_surface(self, frame: RenderFrame) -> None: ...
def draw(self, camera: CameraMatrices, settings: DisplaySettings,
framebuffer: int, viewport: tuple[int, int]) -> RenderStats: ...
def release(self) -> None:
"""Idempotent; correct owning context MUST be current."""
...
class Sensor3DWidget(QOpenGLWidget):
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities.
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic.
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited.
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
parent: QWidget | None = None) -> None: ...
def set_scene(self, assets: SceneAssets) -> None:
"""GUI: retain CPU data, schedule upload when context is current."""
...
def present(self, frame: RenderFrame) -> None: ...
def set_settings(self, settings: DisplaySettings) -> None: ...
def initializeGL(self) -> None: ...
def paintGL(self) -> None: ...
def resizeGL(self, w: int, h: int) -> None: ...
def release_resources(self) -> None: ...
def mousePressEvent(self, event: QMouseEvent) -> None: ...
def mouseMoveEvent(self, event: QMouseEvent) -> None: ...
def wheelEvent(self, event: QWheelEvent) -> None: ...
def hideEvent(self, event: QEvent) -> None: ...
def showEvent(self, event: QEvent) -> None: ...
class ThreeDCoordinator:
def __init__(self, widget: Sensor3DWidget, composer: SceneComposer,
executor: Executor, max_fps: int = 30) -> None: ...
def activate(self, assets: SceneAssets, source_id: str) -> int: ...
def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ...
def tick(self) -> None: ...
def set_visible(self, visible: bool) -> None: ...
def set_settings(self, settings: DisplaySettings) -> None: ...
def close(self) -> None: ...
@property
def state(self) -> SceneState: ...
@property
def generation(self) -> int: ...
class ThreeDPanel(QWidget):
"""UI-only controls; no asset IO, SDK reads, or direct GL calls."""
def __init__(self, coordinator: ThreeDCoordinator, widget: Sensor3DWidget,
parent: QWidget | None = None) -> None: ...
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None: ...
def set_state(self, state: SceneState) -> None: ...
def set_render_stats(self, stats: RenderStats) -> None: ...
+425
View File
@@ -0,0 +1,425 @@
# Studio 三维显示架构与实施规范
版本:1.0 提案;日期:2026-09-14。目标读者:Studio、SDK、Blender 资产、ARM 平台和测试工程师。
本文的“必须”是接口正确性要求,“建议”是第一版实现选择,“待验证”不是已获硬件验收。本文配套 `studio-3d-contracts.pyi` 是接口骨架,不是可运行的三维功能。
## 1. 决策与实施范围
**推荐现有 PyQt5 的 QOpenGLWidget + PyOpenGL,使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。
目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。
| 方案 | 适合的职责 | 对本项目的判断 |
|---|---|---|
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
| Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 |
| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PyQt5 暴露的接口 |
| 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html)
第一版包括:静态完整外观、动态感知表面、旋转/缩放/平移/复位/预设视角、固定或手动色标、图例、显示倍率、外壳显隐、性能与不可用提示。透明外壳、阴影、PBR、多重后处理、局部矢量和三维拾取列为后续项。
## 2. 当前代码与资产事实
代码检查基于当前工作区;审查期间 app HEAD 已更新为 `0d8b79b`,另有滤波相关未提交修改。本提案不覆盖这些修改,不要求恢复已经删除的旧 `record/depth.py`。
| 现有文件/类 | 当前内容 | 复用方式 |
|---|---|---|
| `orisys-sdk/src/orisys/finger/exports.py` / `FingerExportPaths`、`load_finger_export_bundle()` | 寻找并加载 Blender 的网格、内参、位姿 | 保持 SDK 兼容;不要将显示外壳塞进算法网格 |
| `orisys-sdk/src/orisys/unroll/precompute.py` / `load_exported_data()` | 加载 vertices/faces/逐角 UV/K/world_to_cam | 离线导入与旧资产适配 |
| `orisys-sdk/src/orisys/finger/mesh.py` / `build_uv_surface_mesh()` | 从 UV `world_xyz` 构建表面,保存像素采样位置 | 复用几何算法;第一版在加载阶段构建,不每帧重新建拓扑 |
| 同文件 `sample_field_at_mesh_vertices()`、`lift_uv_flow_to_3d()` | 逐顶点采样与光流切向映射 | 封装在 Finger 适配器,不依赖 Open3D viewer |
| 同文件 `lift_uv_deformation_to_3d()`、`deform_mesh_vertices()` | 相对形变示意;存在 normal_gain、近似 pixel_to_world 与显示倍率 | 只能在明确标记 approximate 的通道使用 |
| `orisys-sdk/src/orisys/finger/viewer.py` | Open3D 网格创建与更新 | 留给 SDK 示例,Studio 不导入 |
| `orisys-sdk/examples/viz/mesh_camera.py` | 观察相机;含额外 X/Z 旋转 | 作为迁移对照;不能将旋转常量散落到 Studio shader |
| `orisys-app/shared/src/orisys_app_shared/acquisition/models.py` / `SdkPacket` | flow、depth、valid_mask、会话/帧身份 | 新增一个可选三维字段或独立显示订阅,避免多个散乱字段 |
| `orisys-app/apps/studio/src/orisys_studio/replay.py` | 当前回放视频和力,不保存/直接重算深度;重算属于采集入口 | 普通历史回放不能假装拥有三维测量数据 |
实际读取资产结构(`np.load(..., allow_pickle=False)`):
| 资产目录 | 内容 | 实际结构 |
|---|---|---|
| `sensors/finger/assets/30fps-0703` | `mesh.npz` | vertices float32[1032,3];faces int32[1974,3];uv_data float32[5922,2];loop_to_vertex int32[5922] |
| 同目录 | `camera_intrinsics.npz` / `world_to_cam.npy` | K float64[3,3];宽高 int32 标量;位姿 float32[4,4] |
| 同目录 | `pixel_to_xyz.npz` | xyz_map float32[720,1280,3] |
| 同目录 | `unroll_map_uv_s0.4.npz` | map_x/map_y/valid_mask 为 [300,280];world_xyz 为 [300,280,3];含 source_scale、flip_v、projection_K |
| `sensors/finger/assets/visualization` | `mesh.npz` | vertices[1005,3]、faces[1921,3],与运行网格不是同一个拓扑 |
| 同目录 | `pixel_to_xyz.npz` / `flatten_remap.npz` | xyz_map[500,500,3];展平映射及 flat_xyz[1024,1024,3] |
**不能把 1032 个导出顶点当作最终实时渲染顶点数。** 当前 UV 网格函数是每个有效 UV 像素生成一个顶点,300×280 的网格在全有效时上限为 84,000 顶点;实际数量需要按 valid_mask 统计。两个资产目录也不能按数组下标直接拼接。
`uv_data` 为每个面角的数据,不能当作每个顶点的一份 UV。UV 接缝处若一个几何顶点拥有多个 UV,显示导出必须拆成多个 render vertex,并保留采样映射;不得强行平均 UV。
## 3. 坐标、单位与测量含义
### 3.1 坐标契约
新增场景的公共坐标定义为 `sensor`:右手系、长度单位米。X/Y/Z 的物理方向由资产 manifest 明确描述,并与力轴约定核对;现有 Blender 文件不能在没有证据时自动认定为米或自动认定 +Z 为传感器长轴。
统一记号 `T_A_from_B`:列向量满足 `p_A = T_A_from_B @ p_B`。平移在最后一列。JSON 矩阵按行序列化;上传 shader 时显式按列主序打包,GLES 使用 transpose=false,不让 NumPy 存储布局决定坐标含义。
转换分成三种:
1. **算法相机**:原始 `K`、畸变、`world_to_cam`、裁剪/缩放/flip_v,服务像素到曲面的映射,保留原有精度和版本。
2. **资产坐标**:Blender/glTF/旧 NPZ 到 sensor 的转换。离线发布时一次性烘焙到网格、法线和映射;运行时不再重复应用同一转换。
3. **观察相机**:用户 orbit/pan/zoom,仅改变 view/projection,不改变算法输入、标定和力轴。默认观察相机是显示资产的一部分,不自动等同于真实摄像头。
glTF 使用右手系、Y 向上、长度米;仍需显式转换到本项目的 sensor 轴约定。负缩放或坐标翻转要同步修正三角形绕序;法线使用逆转置,不能直接乘含非均匀缩放的模型矩阵。[glTF 坐标与变换规范](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units)
### 3.2 “形变”不是天然的毫米
当前 `lift_uv_deformation_to_3d()` 对 vnormal 求模,再乘法线;这不能保留一个任意有符号法向位移的含义。`mesh_uv_to_world_scale()` 是由网格跨度估计每 UV 像素对应的世界尺度,不是经过验证的位移标定。
因此帧契约必须分开:
- `scalar`:着色量,带 `quantity`、`unit`、`signed`、`quality`;如 `flow_magnitude / px / false / approximate`。
- `displacement_sensor_m`:若提供,必须说明是 calibrated、approximate 还是仅 display_only;approximate 即使以米存储,也不能以毫米测量值向用户呈现。
- `display_gain`:只作用于显示顶点 `p_display = p_rest + gain * displacement`,不得写回原始记录、改动标定或用放大后的值计算色标。
- 没有可靠位移时,默认只在静态曲面着色。用户启用示意形变时显示“相对形变示意 ×N”,不得把 FN/FX/FY 总力直接当作每个顶点高度。
跨帧比较默认固定色标;有符号量使用围绕零的发散色图,非负量使用顺序色图。无效点显示中性颜色或遮罩,不当零值,超范围值夹取颜色并保留超范围状态。
## 4. 感知表面与非感知结构怎样分开
建议一个场景、两个独立 draw part,不把整机合成一个动态网格:
| part_id / role | 几何来源 | 每帧更新 | 显示规则 |
|---|---|---|---|
| `sensing_surface` / sensing | 真实感知曲面或绑定其采样映射的显示 LOD | 顶点、必要时法线、scalar、valid | 科学色图;参与传感色标和数据查询 |
| `housing` / housing | Blender 简化外壳 | 无;VBO/EBO 只上传一次 | 灰/石墨材质;不参加测量色标和峰值统计 |
| `reference_axes` / reference | 显示辅助几何 | 相机变化时更新视图 | 用户可隐藏;不伪装成测量 |
两部分都转换到 sensor 坐标,分别持有索引和材质。感知面不与外壳共用可变顶点缓冲。Blender 推荐两个明确命名的 Collection/Object,导出器按角色读取,不以颜色猜角色。
第一版外壳使用不透明材质和简单漫反射;感知面采用保留色值的着色,光照不能让同一数值在不同朝向看起来像不同数值。先保留“外壳显示/隐藏”,透明外壳后做:它涉及排序和深度写入,不能只把 alpha 调小。
衔接原则:
- 模型接缝由美术在统一坐标中设计;外壳不复制一层完全重叠的感知面,避免 z-fighting。
- 若显示放大产生明显穿插,可提供“示意形变”倍率上限或仅着色模式;不能为美观强行锁定测量边界并改写数据。
- 外壳 LOD 可独立简化。感知面简化必须同时生成新的采样绑定,不能保留旧 vertex_id/UV 表后换拓扑。
- 拾取后续如加入,返回 part_id;点到 housing 时显示“非感知结构”,不能返回一个虚构力值。
## 5. 文件格式与复制保护
### 5.1 三层资产
| 层 | 推荐格式 | 发布策略 |
|---|---|---|
| 内部源文件 | `.blend`、高精度机械模型、原始相机标定 | 留在受控资产仓库;不随客户程序分发 |
| 制作交换 | 外观采用 `.glb`;算法几何/映射保留现有 NPZ/NPY | 用于美术与导出工具;GLB 是标准交换格式,不是保密格式 |
| Studio 运行包 | 自定义扩展名 `.oryscene`,内部为签名 manifest + 数值数组块;可选加密 | 渲染器仅接收已解包、已验证的 SceneAssets,不感知文件格式 |
第一版无需在 ARM 运行时实现完整 glTF/PBR 引擎:离线 exporter 将 GLB 的必要节点、变换、法线和基础材质编译成紧凑数组;运行时只支持本规范的固定 mesh/material 子集。GLB 动画、骨骼、任意扩展、Draco 等默认不进入第一版。
既有 SDK NPZ/NPY 路径继续兼容。若要加密算法资产,应另行给 SDK 增加只读 AssetProvider/内存流入口;仅加密 Studio 包而继续分发相同明文算法网格,不应宣称那些几何已得到保护。
### 5.2 明确保护目标
用户确认目标为**提高逆向和复制成本**,不是保证客户端无法提取模型。
建议依次落实:
1. **最小交付**:仅发布简化外壳与需要的感知几何;去掉内部机械结构、隐藏对象、原始命名、无用相机、设计历史和高精度源资产。
2. **完整性和来源**:构建机签名发布包,客户端只带验签公钥;manifest 的版本、资源角色、大小、哈希和兼容信息都纳入签名。SHA-256 只能检查内容,不等同于防篡改签名。
3. **可选加密**:使用成熟库的认证加密,例如 AES-GCM;同一密钥下每次包加密的 nonce 必须唯一,元数据作为关联数据绑定。不要自制 XOR、把密码藏在扩展名或把 ZIP 当加密。[认证加密接口及 nonce 约束](https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM)
4. **密钥隔离**:签名私钥不能进入客户软件;内容密钥经 KeyProvider 获取,可按产品/客户授权。若需要离线,明确离线授权/缓存失效流程。Python 内硬编码全局密钥只能提供很弱的阻力;原生加载器、设备密钥保护可提高成本但不能防运行时捕获。
5. **解密范围**:尽量内存解密、不输出明文临时文件、日志不打印密钥;Python/驱动内存副本不能承诺被完全擦除。
即使加密,渲染最终仍需在 CPU/GPU 获得几何。若某结构绝不能泄露,就不要交付该结构,改成视觉代理模型。包格式和加密不能代替这一决策。
### 5.3 `.oryscene` v1 逻辑内容
建议封装采用有大小上限的 manifest 与数组条目,不执行脚本、不使用 pickle。压缩是容量优化,不属于安全机制。读取器拒绝路径穿越、未知必须扩展、异常尺寸、越界索引、非有限坐标及解压超限。
```text
envelope.json # magic、envelope_version、key_id、nonce、签名信息
payload # 可选认证加密;解开后是下列逻辑内容
manifest.json
sensing/positions.npy # float32[N,3],sensor 坐标、米
sensing/normals.npy # float32[N,3],单位向量
sensing/triangles.npy # uint32[M,3]
sensing/sample_indices.npy # int32[N,4],UV 网格展平下标
sensing/sample_weights.npy # float32[N,4],双线性权重
housing/positions.npy
housing/normals.npy
housing/triangles.npy
```
manifest 必须包含:schema_version、asset_id、asset_version、geometry_id、sensor_model、适用硬件修订范围、geometry_sha256、mapping_sha256、coordinate_frame、length_unit、源坐标到 sensor 的烘焙记录、算法网格/相机配置哈希、UV 宽高及原点方向、source_scale/crop/flip_v、各 part_id/role/LOD/材质/数组类型尺寸、所有内容哈希、默认观察相机、编译工具版本。
hash 的定义必须稳定:签名不包含自身;按规定的规范化 manifest 字节及有序条目摘要计算。明文内容哈希用于 geometry_id/兼容性,密文完整性由认证加密与签名处理,二者不能混称。
`sample_indices` 的每一行最多四个有效下标,未使用项为 -1 且权重 0;有效权重和为 1。加载后验证下标属于 manifest 声明的 UV 域。运行期若任一正权重点无效,默认将该顶点标无效,不跨遮罩补值。
编译器与加载器要共用 JSON Schema。下面仅为字段关系示例,尖括号值由编译器生成,不能作为有效发布包:
```json
{
"schema_version": "1.0",
"asset_id": "finger-display",
"asset_version": "1.0.0",
"geometry_id": "<geometry-digest>",
"coordinate_frame": "sensor",
"length_unit": "m",
"compatible": {"sensor_model": "finger", "hardware_revisions": ["<revision>"]},
"field_domain": {
"id": "<domain-digest>", "shape_hw": [300, 280],
"index_order": "row-major", "origin": "top-left",
"source_scale": 0.4, "flip_v": "<actual-value>",
"mapping_sha256": "<mapping-digest>", "camera_sha256": "<camera-digest>"
},
"parts": [
{"id": "sensing_surface", "role": "sensing", "mesh": "sensing", "binding": "uv-lod0"},
{"id": "housing", "role": "housing", "mesh": "housing"}
],
"entries": {
"sensing/positions.npy": {"dtype": "<f4", "shape": ["<N>", 3], "sha256": "<digest>"}
}
}
```
## 6. 模块边界与文件骨架
```text
orisys-app/shared/src/orisys_app_shared/scene3d/
contracts.py # DTO、枚举、形状/单位/身份校验,无 Qt/OpenGL
assets.py # AssetLoader、LegacyFingerImporter、包验证
sampling.py # SurfaceSampler:UV 场 → 顶点字段,纯 NumPy
composer.py # SceneComposer:字段 → RenderFrame,含法线/显示倍率
orisys-app/apps/studio/src/orisys_studio/scene3d/
coordinator.py # 订阅、latest-only 调度、generation/状态、错误
adapters.py # SdkPacket/FingerRuntime → SurfaceFieldFrame
gl_backend.py # GLDispatch、GLRenderer、shader、GPU 资源
camera.py # OrbitCamera;纯数学,不读取算法相机配置
widget.py # Sensor3DWidget(QOpenGLWidget);Qt 事件与上下文
panel.py # 视角/倍率/色标/外壳显隐,委托 coordinator
orisys-app/tools/scene_assets/
compile_scene.py # 离线旧 NPZ + GLB → 运行包
validate_scene.py # 数值、角色、坐标、兼容性和包完整性检查
probe_gl.py # 在实际 Qt 窗口中探测上下文并输出诊断 JSON
```
这些是建议职责,不要求第一天全部拆成独立大类。体量小时 `sampling/composer` 可同文件,shader 可与 backend 同目录;禁止把 SDK、解密、重建网格、OpenGL 和 UI 事件塞到一个 Widget。
依赖方向:
```mermaid
flowchart LR
SDK[SDK / 采集线程] --> A[FrameAdapter]
A --> Q[LatestFrameMailbox]
Q --> C[SceneComposer / CPU 工作线程]
P[AssetLoader / 已验证资产] --> C
C --> R[Latest RenderFrame]
R --> W[Sensor3DWidget / GUI线程]
W --> G[GLRenderer / 当前Qt上下文]
U[Panel / OrbitCamera] --> W
SDK --> D[原有全量记录通道]
```
三维帧丢弃只影响显示,不增加录像/CSV 丢帧数;不能给全量记录通道施加渲染背压。SDK 数学层也不能反向 import Studio 或 OpenGL。
## 7. 核心数据接口
完整签名见同目录 `studio-3d-contracts.pyi`。下面的 shape 是硬性契约;frozen dataclass 不会冻结 NumPy 数组,发布方必须复制或转移所有权并置只读。
| 类型 | 关键字段 | 不变量 |
|---|---|---|
| `FrameIdentity` | source_id、session_id、frame_id、t_rel_s、generation、request_id | source_id 区分实时/录像;generation 切换源时增加;request_id 区分回放请求 |
| `FieldDescriptor` | quantity、unit、quality、signed | 不允许没有单位和质量说明的深度/位移 |
| `SurfaceFieldFrame` | identity、geometry_id、field_domain_id、scalar[H,W]、valid[H,W]、可选 displacement[H,W,3] | 同一个采集帧、同一 UV 域、数组只读;缺失字段不能沿用前一帧 |
| `MeshPart` | part_id、role、positions[N,3]、normals[N,3]、triangles[M,3]、材质 | 米、sensor 坐标;索引小于 N;role 决定是否动态 |
| `SurfaceBinding` | sensing part_id、geometry_id、domain_id、shape_hw、indices[N,4]、weights[N,4] | 与 sensing 顶点数一致;LOD 改变必须更换绑定 |
| `SceneAssets` | asset_id/hash、geometry_id、parts、binding、camera_presets | 第一版一个 sensing part,0..多个 housing;不能把相机预设作为映射 |
| `DisplaySettings` | gain、deform_enabled、ColorScale、housing_visible、max_fps | 只影响显示;同一 scalar_descriptor 才能沿用色标 |
| `RenderFrame` | identity、asset_hash、geometry_id、动态 positions/normals/scalar/valid | 无 GPU handle;携带完整身份,用于拒绝旧结果 |
| `RenderStats` | displayed_frames、superseded_frames、compose_ms、upload_ms、paint_ms、estimated_gpu_bytes | 统计不和录制混用;CPU 提交时间不冒充真实 GPU 执行时间 |
## 8. 类与函数实施合同
### 8.1 资产加载与编译
| 类/函数 | 输入 | 输出 | 线程、错误与副作用 |
|---|---|---|---|
| `AssetLoader.load(path, expected, keys)` | 运行包路径、ExpectedGeometry、可选 KeyProvider | SceneAssets | CPU 工作线程;有文件 IO;签名/哈希/域/单位不匹配抛 ThreeDError,不能退回同名不同资产 |
| `AssetLoader.validate(assets, expected)` | 资产、期望设备/域 | tuple[Diagnostic,...] | 纯校验;error 级诊断禁止发布资产,warning 可显示 |
| `KeyProvider.get_content_key(key_id)` | 密钥标识 | bytes | 平台授权层;不得写日志;加载器不内置通用主密钥 |
| `LegacyFingerImporter.convert(export_dir, map_path, transform, units)` | 旧资产目录、运行 UV 映射、已确认的变换/尺度 | SceneAssets | 仅开发/离线;缺少坐标依据必须拒绝,不从文件名猜 |
| `SceneCompiler.compile(surface, housing_glb, output, options)` | 源资产、可选 GLB、输出、签名/打包选项 | CompileReport | 离线;校验后写临时包,再原子替换目标;私钥通过外部配置提供 |
旧 NPZ 导入器要先验证 `loop_to_vertex` 与面角顺序;LOD 导出后重新生成 binding;保持算法和显示资产各自版本,避免改外壳导致算法标定哈希变化。
`LegacyFingerImporter.convert()` 的 `meters_per_source_unit` 只负责先把源顶点长度换成米;`transform_sensor_from_source` 随后只包含刚体旋转和平移,平移单位为米,不重复含缩放。对 source 法线只应用旋转。GLB 本身使用米,其 transform 同样不重复进行单位换算。
### 8.2 字段适配、采样与合成
| 类/函数 | 输入 | 输出 | 线程与行为 |
|---|---|---|---|
| `FingerFrameAdapter.snapshot(packet, runtime, identity)` | 采集包、当前拥有的 FingerRuntime、身份 | SurfaceFieldFrame 或 None | 只能在 runtime 所在线程读其数组;没有三维订阅时返回 None;复制本帧所需数据,禁止 GUI 再追读 runtime |
| `BoxFrameAdapter.snapshot(packet, identity, domain)` | 平面数据及已确认域 | SurfaceFieldFrame 或 None | 第一版可只支持 scalar;未知空间尺度不能造毫米高度场 |
| `SurfaceSampler.sample(frame, binding)` | 只读 UV 字段、绑定 | VertexFields | CPU;按 indices/weights 向量化采样,检查域;不在每帧搜索最近三角形 |
| `SceneComposer.compose(assets, frame, settings)` | 场景、字段、显示设置 | RenderFrame | CPU 工作线程;应用位移/倍率并更新动态法线;不操作 Qt、不创建 GL handle |
| `SceneComposer.rest_frame(assets, identity)` | 场景与身份 | RenderFrame | 清除旧颜色/位移;用于刚加载、缺数据或断连后的静态参考 |
surface 原始坐标、动态位置和 scalar 分开持有。颜色映射建议在 shader 中用 scalar + LUT + 固定范围完成,避免每帧在 Python 分配大量 RGB 数组;适配器不得每帧遍历全部 Blender 面来建映射。
### 8.3 帧调度与状态
| 类/函数 | 输入 | 输出 | 线程与行为 |
|---|---|---|---|
| `LatestFrameMailbox.offer(frame)` | SurfaceFieldFrame | OfferResult | 采集线程可调用;容量 1,替换旧帧并计数,锁内只换引用 |
| `LatestFrameMailbox.take()` | 无 | frame 或 None | CPU 合成线程;取出并清空;没有帧不忙轮询 |
| `ThreeDCoordinator.activate(assets, source_id)` | 场景与数据源 | generation:int | GUI;取消旧请求、清空两个 mailbox、建立源身份 |
| `ThreeDCoordinator.submit(frame)` | 帧 | OfferResult | 任意生产线程;快速校验身份,错误/过期拒绝;不直接触发 paint |
| `ThreeDCoordinator.tick()` | 无 | None | GUI 定时器;收最新 RenderFrame,调用 widget.present(),按上限请求 update |
| `ThreeDCoordinator.set_visible(bool)` | 可见性 | None | GUI;隐藏时取消三维合成/上传/渲染任务和订阅;不停止采集/记录 |
| `ThreeDCoordinator.set_settings(settings)` | 显示设置 | None | GUI;色标/相机可独立刷新,位移倍率变化需重合成当前字段 |
| `ThreeDCoordinator.close()` | 无 | None | GUI;取消任务,停止定时器,清 mailbox,断开信号;幂等 |
状态机:Disabled → Loading → Ready → Live;缺字段为 NoData,资产/GL 失败为 Unavailable。隐藏为 Suspended,恢复时用最新帧;断连时清旧动态帧并显示参考模型/“无实时数据”。过期帧、旧 generation 和旧 request_id 不得重新覆盖新场景。
两个 mailbox 各容量 1,加一个正在计算的帧;Qt 信号只通知状态,不排队传递每帧大数组。CPU 任务不得无限提交给线程池。完整数据记录继续走原有通道。
标准失败语义:
| code | 触发 | 调用方处理 |
|---|---|---|
| `asset_auth_failed` | 签名/认证标签错误 | 拒绝资产,进入 Unavailable;不尝试明文回退 |
| `geometry_mismatch` | 场景、绑定、设备几何不一致 | 拒绝帧/资产,显示预期与实际 ID |
| `field_domain_mismatch` | H/W、scale、flip 或域不同 | 拒绝帧;不能自动 resize 后继续冒充原映射 |
| `field_unavailable` | 当前帧缺 scalar/有效数据 | NoData,清除旧测量颜色;允许参考模型 |
| `stale_frame` | generation/request_id 过期 | 安静丢弃并计数,不弹窗打断操作 |
| `gl_unsupported` | 实际上下文达不到所选 profile | Unavailable,二维照常;附 probe 信息 |
| `shader_compile_failed` | shader 编译/链接失败 | Unavailable,日志保留 shader 版本和错误信息 |
| `gpu_budget_exceeded` | 分配超过配置预算 | 显式选择更低 LOD 或拒绝三维,不无限重试 |
参考帧 `rest_frame()` 的 scalar 全部不可用,descriptor 使用 `quantity="unavailable"`、`unit="1"`、`quality="display_only"`,UI 隐藏测量色条。渲染器不得因为它与先前色标不匹配而留下旧颜色。
### 8.4 OpenGL 与 Qt
| 类/函数 | 输入 | 输出 | 限制 |
|---|---|---|---|
| `configure_surface_format(profile)` | desktop33 / gles30 | None | QApplication 创建之前;只选一个已验证平台配置 |
| `GLDispatch.bind_current_context()` | 已经 current 的 Qt context | GLCapabilities | 选择和实际 GL/ES 匹配的函数绑定;不自行创建第二套 EGL 窗口 |
| `GLRenderer.initialize(capabilities)` | 实际能力 | None | GUI/current context;编译对应 shader,失败抛 shader diagnostic |
| `GLRenderer.upload_scene(assets)` | 验证后的资产 | None | 静态 VBO/EBO/LUT 一次分配;建立预算 |
| `GLRenderer.update_surface(render_frame)` | 同资产动态帧 | None | 更新既有动态缓冲;不重建 housing/索引 |
| `GLRenderer.draw(camera, settings, framebuffer, viewport)` | 相机矩阵、设置、Qt FBO、物理像素尺寸 | RenderStats | 一次场景绘制,不读磁盘、不调用 SDK、不处理 Qt 事件循环 |
| `GLRenderer.release()` | 无 | None | 必须正确 context current;幂等;GPU 资源清零 |
| `Sensor3DWidget.initializeGL()` | Qt 调用 | None | 创建 backend,连接 context.aboutToBeDestroyed;失败进入 Unavailable |
| `Sensor3DWidget.paintGL()` | Qt 调用 | None | 验证最新帧身份,上传并绘制;无新帧只在相机/设置变化时重画 |
| `Sensor3DWidget.resizeGL(w,h)` | Qt 尺寸 | None | 校准 DPR 与实际 framebuffer 尺寸,更新投影;不缓存旧 FBO |
| `Sensor3DWidget.present(frame)` | RenderFrame | None | GUI,仅保留 CPU 帧并调用 update;不要求调用点已 current |
| `Sensor3DWidget.release_resources()` | 无 | None | makeCurrent → release → doneCurrent;销毁/重建/关闭均能安全重复 |
Qt 的 widget 使用自身 FBO,不能把默认目标绑回 0;Qt 生命周期入口已经提供 current context,其他入口需显式管理。重新挂接窗口可能导致 context 重建,要从 CPU 资产恢复 GPU 资源。清理绑定 aboutToBeDestroyed,同时在关闭时显式执行。[Qt 5.15 QOpenGLWidget](https://doc.qt.io/qt-5/qopenglwidget.html)
shader 维护两个头部变体:桌面 `#version 330 core`;ES `#version 300 es`,显式 float/int precision。共享计算逻辑,禁止 glBegin/glEnd、固定管线矩阵、GL_QUADS、GLU/GLUT。第一版不依赖 geometry shader、compute shader、桌面专用 polygon mode。
buffer 使用连续 float32 和合法的 uint32 索引;draw call 按 part 合批。若最终必须支持 GLES2,作为独立降级项目处理着色器、VAO 和 16 位索引限制,不能以把版本号改成 2.0 作为完成。
### 8.5 相机和 UI 接口
| OrbitCamera 方法 | 输入 | 输出/行为 |
|---|---|---|
| `fit(bounds, aspect)` | 场景包围盒、宽高比 | None;设置 center/distance/near/far,使完整模型可见 |
| `orbit(delta_px, viewport)` | 鼠标二维增量、视口 | None;围绕 target 旋转,不旋转模型数据 |
| `pan(delta_px, viewport)` | 鼠标增量、视口 | None;按距离/投影换算世界平移 |
| `zoom(wheel_steps)` | 滚轮步长 | None;指数缩放并限制近远范围,避免穿模 |
| `set_preset(preset)` | CameraPreset | None;前/侧/顶/等轴测/默认 |
| `matrices(viewport)` | 物理视口尺寸 | CameraMatrices(view, projection) |
建议交互:左键旋转,Shift+左键或中键平移,滚轮缩放,复位按钮回资产默认视角。手势保存在 panel/widget,具体轨迹计算在 OrbitCamera。相机、外壳显隐、倍率与色标可存 UI preferences,不改变 SDK 参数。
Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形变开关与倍率、色标范围/自动范围、量名称/单位/quality、帧号/数据不可用提示。默认不把算法相机内参暴露成用户的观察相机控件。
## 9. 现有文件怎样接入
| 文件 | 类/函数入口 | 最小修改 |
|---|---|---|
| `apps/studio/src/orisys_studio/main.py` | `main()` | 在 QApplication 前选择 GL profile;避免 app_window 的导入链提前加载错误 PyOpenGL platform |
| `shared/.../acquisition/models.py` | `SdkPacket` | 可选 `surface_frame` 或显示订阅 DTO;向后兼容默认 None,模型不 import Qt/OpenGL |
| `shared/.../acquisition/finger_worker.py` | 实际采集循环的组包位置 | 仅订阅时,在 SDK 已计算完且 runtime 线程拥有数据时调用 adapter;发布带 UV 域身份的只读快照 |
| `shared/.../acquisition/sdk_worker.py` | 平面组包位置 | 输出已经确认语义的 scalar/有效遮罩;无位移不伪造 |
| `apps/studio/src/orisys_studio/app_window.py` | `StudioMainWindow.__init__()`、`_on_run_tick()`、`_render_run_image()`、`closeEvent()` | 只装配 panel/coordinator、切换视图与关闭;不写 GL 调用、资产解密或顶点映射 |
| `apps/studio/src/orisys_studio/replay.py` | 打开录像、`set_position()`、关闭 | 对三维能力显式返回 unavailable;只有独立重算/保存的场数据源才发布 SurfaceFieldFrame |
| `apps/studio/pyproject.toml` 或项目依赖入口 | optional dependencies | 新增 three-d 安装项及每平台锁定文件;Open3D 不进入默认 Studio 依赖 |
| `tools/` 与 CI | 新 probe/export/validate 测试 | 纯 CPU 合同测试与真窗口 GL 冒烟分开 |
当前工作区正在改动采集滤波,工程师接入前应按函数职责定位,不依据本提案的旧行号覆盖文件。不得从 GUI 线程直接读取 worker.sensor 或修改它的图形状态。
## 10. 实时、回放与记录
实时:感知图/三维/力曲线共享 FrameIdentity;显示可降帧,记录通道保持既有语义。新 scalar 或 displacement 的计算不得为三维单独再滤一次,导致与二维不一致。
普通现有记录仅有视频和力时:显示静态参考模型并提示“三维场未记录”。完整重算必须通过独立数据源,携带 SDK/算法配置/几何/标定版本与恢复质量;不能自动重启已经删除的旧深度重算类。
未来如增加可追溯三维记录,建议另存分块 SurfaceFieldFrame 数据与 frame_id 索引,记录 quantity/unit/quality/domain_id/geometry_id,以及场是否 calibrated;不要仅保存着色后的网格或 UI 屏幕。session.json 增加可选 `visualization3d` 能力/资产版本块,旧文件缺此块视为不支持。此记录扩展不是第一版实时渲染的前置条件。
回放拖动时递增 request_id,CPU 合成结束后再次核对 request_id;无效/缺帧必须清理上次动态结果,不能让光标已前进但三维还冒充当前帧。
## 11. ARM 与安装/打包规范
### 11.1 依赖
- 继续使用项目验证过的 Python、PyQt5、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
拟定安装入口(工程师实现 extras 后才可执行):
```text
python -m pip install -e ./apps/studio[three-d]
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
python tools/scene_assets/probe_gl.py --profile gles30 --output gl-report.json
```
两种 profile 是不同启动配置,先按实际平台选其一;不要求同一进程依次更换 Qt/PyOpenGL 平台。PyOpenGL 的 platform 选择必须在第一次 import OpenGL 之前完成,并匹配 Qt 实际使用的 GLX/EGL;不能对所有 Linux 机器一律强设 EGL。探测程序必须建立实际 Qt widget,输出取得的 context,而非只查询系统库是否存在。
### 11.2 待补验收矩阵
| 平台 | 首轮验证路径 | 必须记录 |
|---|---|---|
| 开发机 Windows | desktop33 | GPU/驱动、Qt/Python/PyOpenGL、实际 GL/GLSL、DPR |
| Jetson Orin Nano / Ubuntu | BSP 支持的 desktop GL;需要时另验证 ES | 板卡内存、JetPack/BSP、桌面会话、功耗模式、驱动/renderer、Qt 实际 backend |
| QCS6490 / Ubuntu | BSP 支持的 EGL/GLES3 路径优先验证 | 模组厂商/BSP、Ubuntu、Wayland/X11、EGL/GLES、Qt 构建、驱动能力 |
不根据 SoC 名称猜桌面图形支持。QCS6490 与 Jetson 最终 profile 由探测报告决定;若驱动/绑定不满足,保持二维功能并显示具体原因。不得把 llvmpipe 等软件渲染误报为目标 GPU 验收通过。
## 12. 性能预算与验收用例
建议起始目标,不是目前测量结果:动态感知面约 20k 顶点/40k 三角形,外壳约 10k–20k 三角形;实际 LOD 由曲面误差和上板性能共同决定。将 full UV 网格保留为参考质量模式。
建议 30 FPS 显示上限;低配可明确降为 15 FPS,采集和记录速率不能因此下降。以 20k 顶点、position/normal/scalar/valid 约 32 字节计算,单份动态数组约 0.64 MB;此估算不含多份 CPU/GPU 缓冲、输入 UV 场、纹理和驱动开销。
| 测试 | 通过标准 |
|---|---|
| 原点/轴/尺度 | 三个非共线已知点,表面与外壳对齐;法线方向、绕序、米/毫米换算正确 |
| 资产身份 | 1005/1032 拓扑混用、UV 尺寸不同、flip_v 不同、错误哈希均被拒绝 |
| 采样 | 已知常量/斜坡/单点脉冲/NaN/遮罩场与 CPU 参考一致;接缝无跨域插值 |
| 几何分层 | 改 scalar/gain 只更新 sensing;housing 顶点和 GPU 静态资源不变 |
| 测量含义 | approximate 不显示成已标定位移;增大 gain 不改变原始 scalar 和力值 |
| 生命周期 | 隐藏/显示、反复 resize、切换布局、断连、源切换、关闭以及上下文重建无崩溃/泄漏 |
| 过期帧 | 旧 source/generation/request_id 永远不能覆盖新场景;无数据不显示旧测量 |
| 渲染限制 | shader 故障/GL 不足时降到二维并给诊断;无软件 renderer 冒充 GPU |
| 记录隔离 | 同样输入下,开/关三维的 CSV 行序和有效样本相同;三维超时不阻塞记录 |
| 性能 | 上板统计 p50/p95 合成、上传提交、绘制、总响应延迟,报告分辨率/网格/功耗配置;无无限任务增长 |
| 长时 | 建议 2 小时采集+记录+三维后,CPU/GPU 内存不持续增长;报告最大 RSS 和队列上界 |
| 包保护 | 改 manifest/密文/条目哈希被拒绝;无明文临时资产;客户包无签名私钥/内部源模型 |
CPU 合同测试在无 GUI 环境运行;GL 测试必须使用可见/实际窗口系统的上下文,offscreen UI 测试不能代替 ARM GPU 验证。
## 13. 实施拆分与交付物
1. **平台 PoC**:probe_gl、三角形、静态感知网格+外壳、resize/关闭测试;两块 ARM 至少拿到能力报告,再冻结 GLDispatch/profile。
2. **资产合同**:旧 NPZ 导入、统一坐标、roles、manifest、绑定验证和一套简化外壳;不改 SDK 算法网格。
3. **实时数据**:FrameAdapter、SurfaceSampler、SceneComposer、最新帧调度;先 scalar 着色,再加明确标记的示意位移。
4. **Qt 产品接入**:Sensor3DWidget、相机/色标/倍率/显隐、异常与生命周期、现有记录隔离测试。
5. **发布保护与性能**:精简/签名包、可选加密与 KeyProvider、平台锁定、性能/长时/故障测试。
每项交付包括:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告。先评审本文的坐标/场语义/资产合同,再开始组件并行开发;不要等画面完成后才决定数组每一维和单位的含义。
+8 -2
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "orisys-app"
version = "0.1.0"
description = "Orisys desktop applications (customer + future internal)"
description = "Orisys desktop applications (studio + future internal)"
requires-python = ">=3.10"
readme = "README.md"
authors = [{ name = "Orisys-Tech" }]
@@ -13,8 +13,14 @@ authors = [{ name = "Orisys-Tech" }]
[project.optional-dependencies]
dev = [
"orisys-app-shared @ file:./shared",
"orisys-customer @ file:./apps/customer",
"orisys-studio @ file:./apps/studio",
"pytest>=7.4",
]
[tool.setuptools]
packages = []
[tool.pytest.ini_options]
testpaths = ["shared/tests", "apps/studio/tests"]
pythonpath = ["shared/src", "apps/studio/src"]
addopts = "-q"
@@ -1,18 +1,15 @@
"""Acquisition helpers."""
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
__all__ = [
"FORCE_SAMPLE_RATE_HZ",
"LOWPASS_CUTOFF_HZ",
"FingerAcquisitionWorker",
"SdkAcquisitionWorker",
"SdkPacket",
"depth_map_to_bgr",
"draw_arrow_display",
"flow_colormap",
"hex_to_bgr",
"load_fixed_mode",
"orisys_importable",
@@ -28,6 +25,7 @@ _LAZY_ATTRS = {
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
"flow_colormap": ("orisys_app_shared.acquisition.arrows", "flow_colormap"),
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
}
@@ -2,24 +2,29 @@
from __future__ import annotations
from functools import lru_cache
from typing import Optional, Sequence
import cv2
import numpy as np
from orisys_app_shared.theme import color
ARROW_BG_BGR = (0x16, 0x13, 0x12)
_ARROW_DRAW_KW = dict(
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
_FINGER_ARROW_DRAW_KW = dict(
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
below_threshold_color=(160, 160, 160),
)
"""Fallback canvas colour (dark theme ``arrow_canvas``)."""
FLOW_RAMP_FALLBACK = "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
"""Design-spec flow ramp: brand navy, violet, magenta, coral, lime."""
FLOW_IDLE_HEX = "#8A93A6"
"""Brand-neutral tint for the grid dots below the flow threshold."""
#: BGR grey used for depth samples that carry no value.
NO_DEPTH_BGR = (0x8A, 0x8A, 0x8A)
_LUT_STEPS = 256
_RAMP_MIN_STOPS = 2
_RAMP_MAX_STOPS = 8
def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]:
@@ -35,6 +40,81 @@ def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) ->
return (b, g, r)
_ARROW_DRAW_KW = dict(
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
)
_FINGER_ARROW_DRAW_KW = dict(
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
)
def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]:
stops: list[tuple[int, int, int]] = []
for token in str(ramp or "").split(","):
text = token.strip().lstrip("#")
if len(text) != 6:
continue
try:
stops.append((int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)))
except ValueError:
continue
return stops
@lru_cache(maxsize=8)
def _lut_from_ramp(ramp: str) -> np.ndarray:
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user
colormaps. Invalid input never raises: it falls back to
:data:`FLOW_RAMP_FALLBACK`.
"""
stops = _parse_ramp(ramp)
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
positions = np.linspace(0.0, 1.0, _LUT_STEPS)
span = np.linspace(0.0, 1.0, len(stops))
lut = np.empty((_LUT_STEPS, 1, 3), dtype=np.uint8)
for channel in range(3): # source order: R, G, B
values = np.interp(positions, span, [stop[channel] for stop in stops])
lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
return lut[:, :, ::-1].copy()
def flow_colormap(theme: str = "dark") -> np.ndarray:
"""Return the branded flow-magnitude colour table for ``theme``.
Cached on the resolved ramp string rather than the theme name, so a theme
switch is picked up as soon as :func:`build_qss` reloads the tokens.
"""
return _lut_from_ramp(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK))
def arrow_display_scale(draw_style: str = "box") -> float:
"""How much the drawn arrows amplify the measured displacement.
``orisys.util.draw_arrows`` divides the displacement by ``arrow_scale``, so
the effective display magnification is its reciprocal (box ``0.5`` -> ``2x``,
finger ``1.0`` -> ``1x``). The drawn length is additionally capped at
``grid_spacing * 0.8`` pixels, so the returned factor describes the mapping
only while an arrow has not hit that cap — the UI says so in its tooltip.
"""
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
try:
scale = float(draw_kw["arrow_scale"])
except (KeyError, TypeError, ValueError):
return 1.0
if scale <= 0:
return 1.0
return 1.0 / scale
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
@@ -53,19 +133,22 @@ def draw_arrow_display(
canvas_bgr: Sequence[int] | None = None,
draw_style: str = "box",
valid_mask: Optional[np.ndarray] = None,
colormap: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Draw flow arrows on a solid canvas or over the processed frame."""
base = _ensure_bgr(processed_frame)
if flow is None:
return base
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
draw_kw = dict(_FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW)
if colormap is not None:
draw_kw["colormap"] = colormap
try:
import orisys
if background == "pure_color":
canvas = np.empty_like(base)
color = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
canvas[:, :] = color
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
canvas[:, :] = color_bgr
return orisys.util.draw_arrows(
canvas, flow, valid_mask=valid_mask, **draw_kw
)
@@ -74,8 +157,23 @@ def draw_arrow_display(
return base
def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
"""Convert a float depth/divergence map to a JET colormap BGR image."""
def depth_map_to_bgr(
depth: Optional[np.ndarray],
colormap: Optional[np.ndarray] = None,
*,
value_range: Optional[tuple[float, float]] = None,
invalid_mask: Optional[np.ndarray] = None,
) -> Optional[np.ndarray]:
"""Convert a float depth/divergence map to a colormap BGR image.
``value_range`` pins the colour scale. Per-frame re-normalisation (the old
behaviour) makes the colours drift from frame to frame, so two frames can
look identical while representing different depths — pass the same range
across a session when frames are meant to be comparable.
``invalid_mask`` marks samples that carry no depth. They are painted with a
neutral grey so "no data" can never be mistaken for "zero depth".
"""
if depth is None:
return None
arr = np.asarray(depth, dtype=np.float64)
@@ -84,10 +182,28 @@ def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
finite = np.isfinite(arr)
if not np.any(finite):
return None
lo = float(np.nanmin(arr[finite]))
hi = float(np.nanmax(arr[finite]))
if hi > lo:
normalized = ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)
if value_range is not None and value_range[1] > value_range[0]:
lo, hi = float(value_range[0]), float(value_range[1])
else:
normalized = np.zeros(arr.shape, dtype=np.uint8)
return cv2.applyColorMap(normalized, cv2.COLORMAP_JET)
lo = float(np.nanmin(arr[finite]))
hi = float(np.nanmax(arr[finite]))
if hi > lo:
scaled = (arr - lo) / (hi - lo)
else:
scaled = np.zeros(arr.shape, dtype=np.float64)
scaled = np.clip(scaled, 0.0, 1.0)
normalized = (scaled * 255.0).astype(np.uint8)
image = cv2.applyColorMap(
normalized, cv2.COLORMAP_JET if colormap is None else colormap
)
missing = ~finite
if invalid_mask is not None:
mask = np.asarray(invalid_mask, dtype=bool)
if mask.shape[:2] == image.shape[:2]:
missing = missing | ~mask
if np.any(missing):
#: Neutral grey — deliberately outside the JET ramp so it reads as
#: "undefined" rather than as a depth value.
image[missing] = NO_DEPTH_BGR
return image
@@ -1,4 +1,6 @@
"""Shared acquisition constants (no heavy imports)."""
LOWPASS_CUTOFF_HZ = 10.0
#: The rate the SDK designs its force low-pass coefficients for until the device
#: measures its own. Also the assumption the plots and a recording's time base
#: are built on, so it stays a constant rather than a per-run guess.
FORCE_SAMPLE_RATE_HZ = 30.0
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
@@ -9,21 +10,34 @@ from typing import Callable, Optional
import numpy as np
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
# The packet and the geometry come from ``models``, so the finger worker does not
# depend on the box worker; only genuinely shared helpers are imported from it.
from orisys_app_shared.acquisition.models import (
FixedCameraMode,
SdkPacket,
ensure_bgr as _ensure_bgr,
is_file_source as _is_file_source,
load_fixed_mode,
orisys_importable,
)
from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S,
FPS_WINDOW_S,
HANDOVER_TIMEOUT_S,
STOP_JOIN_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S,
FixedCameraMode,
SdkPacket,
_ensure_bgr,
_is_file_source,
load_fixed_mode,
orisys_importable,
_capture_sequence,
calibration_digest,
capture_status_of,
loaded_calibration_version,
loaded_path,
process_chain,
read_forces_from_sensor,
)
from orisys_app_shared.force.filters import ALL_KEYS, FilterSettings, effective_filters
logger = logging.getLogger(__name__)
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
@@ -51,7 +65,7 @@ def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
return candidate.resolve()
class FingerAcquisitionWorker:
class FingerAcquisitionWorker(RecordSinkMixin):
"""Background loop: FingerRuntime grab/unroll + deformation + force readout."""
def __init__(
@@ -59,20 +73,76 @@ class FingerAcquisitionWorker:
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None,
on_reset_applied: Optional[Callable[[int, float, int], None]] = None,
):
self._init_sinks()
self._on_error = on_error
self._on_status = on_status
#: Called from the acquisition thread once new coefficients are loaded,
#: with ``(path, version, frame_id, mono_t, session_id)``.
self._on_calibration_applied = on_calibration_applied
#: Called from the acquisition thread once a queued zeroing took effect.
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
self._runtime = None
# Keyed by session so a session that is still winding down can only
# ever close its own runtime, never the runtime of a newer session.
self._runtimes: dict[int, object] = {}
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._export_dir: Optional[Path] = None
self._end_reason: Optional[str] = None
self._phase: str = "idle"
self._pending_calibration: Optional[str] = None
#: A zeroing requested from another thread, applied by this one.
self._pending_reset: bool = False
self._mono_t0: float = 0.0
self._calibration_version: str = ""
#: What the filters should do; set before ``start()`` (see the box worker
#: for the reasoning: one session, one way of filtering).
self._filter_settings: FilterSettings = FilterSettings()
#: ``filters_state()`` of the running session: the effective values.
self._filter_state: dict = {}
#: Keys the installed SDK could not apply.
self._filters_unsupported: tuple[str, ...] = ()
#: The capture rate this session measured for itself (the driver's
#: reported rate is an echo of the request and cannot be trusted).
self._measured_capture_fps: float = 0.0
def set_filter_settings(self, settings: Optional[FilterSettings]) -> None:
"""Choose the filters for the **next** session."""
with self._lock:
self._filter_settings = (settings or FilterSettings()).normalised()
def filter_settings(self) -> FilterSettings:
with self._lock:
return self._filter_settings
def filter_state(self) -> dict:
"""What the running device reports as in effect (``{}`` if it cannot say)."""
with self._lock:
return dict(self._filter_state)
def filters_unsupported(self) -> tuple[str, ...]:
"""Settings the installed SDK could not apply, reported not swallowed."""
with self._lock:
return tuple(self._filters_unsupported)
def capture_status(self) -> dict:
"""What the camera was asked for, what it gave, and what we measured."""
with self._lock:
runtime = next(iter(self._runtimes.values()), None)
measured = float(self._measured_capture_fps or 0.0)
tactile = getattr(runtime, "tactile", None) if runtime is not None else None
raw_sensor = getattr(tactile, "raw_sensor", None) if tactile is not None else None
status = capture_status_of(raw_sensor)
if status and measured > 0.0:
status["measured_fps"] = measured
return status
@property
def is_running(self) -> bool:
@@ -99,7 +169,10 @@ class FingerAcquisitionWorker:
*,
config_ref: str | Path,
) -> FixedCameraMode:
# Non-blocking: a previous session that is stuck opening must never hold
# the caller (the GUI thread) hostage.
self.stop()
previous = self._thread
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
@@ -111,36 +184,116 @@ class FingerAcquisitionWorker:
self._latest = None
self._end_reason = None
self._phase = "connecting"
# This session starts with no calibration of its own: an uncalibrated
# runtime must not inherit the previous session's version, and a stale
# zeroing request must not fire into the new session's frames.
self._calibration_version = ""
self._pending_reset = False
# Nor any filter state: until this session's sensor has been
# configured and asked, there is nothing to report about it.
self._filter_state = {}
self._filters_unsupported = ()
self._session_id += 1
session = self._session_id
self._stop.clear()
# Fresh monotonic origin for this session.
self._mono_t0 = time.perf_counter()
self._thread = threading.Thread(
target=self._run,
name=f"FingerAcquisition-{session}",
daemon=True,
args=(session,),
args=(session, previous),
)
self._thread.start()
return mode
def stop(self) -> None:
def stop(self, *, wait: bool = False) -> None:
"""Ask the acquisition thread to stop.
The default is deliberately **non-blocking**: an open that never returns
must not freeze the caller, and the worker thread owns its own teardown.
Use ``wait=True`` only on shutdown paths that must not leave the runtime
behind.
"""
self._set_end_reason("stopped")
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=8.0)
if wait and thread is not None and thread.is_alive():
thread.join(timeout=STOP_JOIN_TIMEOUT_S)
if thread is None or not thread.is_alive():
self._settle(owner=thread)
# Otherwise the thread settles itself on the way out.
def _settle(self, *, owner: Optional[threading.Thread]) -> None:
"""Release worker state once no thread owns the runtime any more."""
if owner is not None and self._thread is not owner:
return # a newer session owns the worker now
self._thread = None
self._close_runtime()
self._close_all_runtimes()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
"""Request a tracker reset, applied by the acquisition thread.
The runtime's tactile reset from the GUI thread would race the frame the
loop is processing, so the zeroing happens at a frame boundary on the
acquisition thread, which reports the frame and time it took effect.
"""
with self._lock:
runtime = self._runtime
if runtime is None:
raise RuntimeError("Finger runtime is not running")
runtime.tactile.reset()
if self._session_id <= 0:
raise RuntimeError("Finger runtime is not running")
self._pending_reset = True
def _consume_pending_reset(self, runtime, frame_id: int, mono_t: float) -> None:
"""Apply a queued reset and report the frame it actually took effect at."""
with self._lock:
pending = self._pending_reset
self._pending_reset = False
session = self._session_id
if not pending or runtime is None:
return
tactile = getattr(runtime, "tactile", None)
target = tactile if callable(getattr(tactile, "reset", None)) else None
if target is None:
sensor = getattr(tactile, "raw_sensor", None)
target = sensor if callable(getattr(sensor, "reset", None)) else None
if target is None:
raise RuntimeError("runtime does not support reset")
target.reset()
if self._on_reset_applied is not None:
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
def set_pending_calibration(self, path: str | Path | None) -> None:
"""Queue a force_calibration.json path for the acquisition thread."""
with self._lock:
self._pending_calibration = str(path) if path is not None else None
def _consume_pending_calibration(
self, sensor, frame_id: int = 0, mono_t: float = 0.0
) -> None:
"""Apply queued coefficients and report confirmation, not intent.
A rejected document leaves the version alone on purpose: the sensor still
has the coefficients it had. What never happens is inventing a version for
a device that has no calibration — that is the empty string.
"""
with self._lock:
path = self._pending_calibration
self._pending_calibration = None
session = self._session_id
if path is None or sensor is None:
return
if not hasattr(sensor, "set_calibration"):
raise RuntimeError("sensor does not support set_calibration")
sensor.set_calibration(path)
version = calibration_digest(path)
self._calibration_version = version
if self._on_calibration_applied is not None:
self._on_calibration_applied(
str(path), version, int(frame_id), float(mono_t), int(session)
)
def _set_end_reason(self, reason: str) -> None:
with self._lock:
@@ -151,9 +304,10 @@ class FingerAcquisitionWorker:
with self._lock:
self._phase = phase
def _close_runtime(self) -> None:
runtime = self._runtime
self._runtime = None
def _close_runtime(self, session: int) -> None:
"""Close the runtime opened by ``session`` (no-op if it owns none)."""
with self._lock:
runtime = self._runtimes.pop(session, None)
if runtime is None:
return
try:
@@ -161,13 +315,38 @@ class FingerAcquisitionWorker:
except Exception:
pass
def _close_all_runtimes(self) -> None:
with self._lock:
sessions = list(self._runtimes)
for session in sessions:
self._close_runtime(session)
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
try:
self._run_session(session, previous)
finally:
# The finishing thread owns the teardown — this is what lets
# ``stop()`` return immediately without leaking the runtime.
self._close_runtime(session)
if self._thread is threading.current_thread():
self._thread = None
with self._lock:
self._latest = None
self._phase = "idle"
def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None:
from orisys.finger import FingerRuntime
if previous is not None and previous.is_alive():
# Wait here rather than on the GUI thread, so the runtime is never
# opened twice at the same time.
self._status("Waiting for the previous session to finish…")
previous.join(timeout=HANDOVER_TIMEOUT_S)
mode = self._mode
source = self._video_source
if mode is None or source is None:
@@ -182,24 +361,42 @@ class FingerAcquisitionWorker:
}
if self._export_dir is not None:
open_kw["export_dir"] = str(self._export_dir)
self._runtime = FingerRuntime.open(**open_kw)
runtime = FingerRuntime.open(**open_kw)
with self._lock:
self._runtimes[session] = runtime
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
self._close_runtime(session)
return
self._runtime.warmup(color=False)
runtime.warmup(color=False)
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
self._close_runtime(session)
return
raw_sensor = getattr(self._runtime.tactile, "raw_sensor", None)
raw_sensor = getattr(runtime.tactile, "raw_sensor", None)
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
# The filter chain lives in the SDK; the app says what the
# operator asked for and reads back what the device is doing.
with self._lock:
settings = self._filter_settings
try:
raw_sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
except Exception:
pass
unsupported = settings.apply_to(raw_sensor)
except Exception: # noqa: BLE001 - a finger runtime may refuse
logger.exception("Could not apply filter settings to the finger sensor")
unsupported = ALL_KEYS
with self._lock:
self._filter_state = effective_filters(raw_sensor)
self._filters_unsupported = tuple(unsupported)
self._status(f"Streaming finger {mode.label()}")
# The tactile sensor loads its own bundle calibration while opening;
# name those coefficients from the first frame, not the request path.
tactile = getattr(runtime, "tactile", None)
loaded = loaded_calibration_version(tactile) if tactile else ""
# Assigned even when empty: an uncalibrated device reports *no*
# calibration, never the previous session's version.
self._calibration_version = loaded
if loaded and self._on_calibration_applied is not None:
self._on_calibration_applied(
str(loaded_path(tactile) or ""), loaded, 0, 0.0, int(session)
)
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
@@ -208,10 +405,18 @@ class FingerAcquisitionWorker:
return
frame_times: list[float] = []
#: (collection time, published sequence), so the camera's own rate
#: can be told apart from the rate this loop consumes (see the box
#: worker: the two are the same measurement and must mean the same).
source_times: list[tuple[float, int]] = []
last_source_seq = None
capture_fps = 0.0
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
runtime = self._runtime
# Per-session monotonic frame index: the join key shared by the video,
# the CSV and the calibration records.
frame_id = 0
while not self._stop.is_set() and session == self._session_id and runtime is not None:
try:
@@ -245,24 +450,66 @@ class FingerAcquisitionWorker:
none_streak_start = None
tactile = runtime.tactile
compute_error = ""
# The frame's acquisition time, so an event that happens on this
# frame is dated when it happened rather than when it was queued.
frame_mono = max(0.0, time.perf_counter() - self._mono_t0)
try:
tactile.compute_deformation(decompose=True, check_motion=False)
tactile.compute_contact()
except Exception:
pass
self._consume_pending_calibration(tactile, frame_id, frame_mono)
except Exception as exc: # noqa: BLE001
message = f"calibration rejected: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id:
self._on_error(message)
compute_error = message
try:
# Applied between two frames on this thread, then reported.
self._consume_pending_reset(runtime, frame_id, frame_mono)
except Exception as exc: # noqa: BLE001
message = f"reset failed: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id:
self._on_error(message)
# The same chain the offline depth replay runs on the tactile.
chain_depth, chain_error = process_chain(tactile)
compute_error = compute_error or chain_error
t1 = time.perf_counter()
frame_times.append(t1)
cutoff = t1 - 1.0
frame_times.append(t0)
cutoff = t1 - FPS_WINDOW_S
frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0
# A *rate*, like the box view: counting frames in a one-second
# window reads one frame high, and the two views have to agree
# about what "fps" means.
span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0
fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0
# The camera's own rate, so this view can attribute a low rate too.
delivered = _capture_sequence(tactile)
if delivered is not None and delivered != last_source_seq:
last_source_seq = delivered
source_times.append((t0, delivered))
source_cutoff = t0 - FPS_WINDOW_S
source_times = [
item for item in source_times if item[0] >= source_cutoff
]
if len(source_times) > 1:
source_span = source_times[-1][0] - source_times[0][0]
published = source_times[-1][1] - source_times[0][1]
if source_span > 0.0:
capture_fps = max(0.0, published / source_span)
with self._lock:
self._measured_capture_fps = capture_fps
forces = read_forces_from_sensor(
tactile,
config_ref=mode.config_ref,
)
invalid_reason = forces.get("invalid_reason") or compute_error
raw_frame = _ensure_bgr(source_frame)
# The frame the tactile chain consumed, kept for depth replay.
self.note_preroll(raw_frame)
packet = SdkPacket(
raw_frame=_ensure_bgr(source_frame),
raw_frame=raw_frame,
processed_frame=_ensure_bgr(unrolled),
timestamp=time.time(),
wall_t=time.time(),
mono_t=max(0.0, t1 - self._mono_t0),
fps=fps,
process_ms=(t1 - t0) * 1000.0,
normal_force=forces["normal_force"],
@@ -273,14 +520,30 @@ class FingerAcquisitionWorker:
shear_y_raw=forces["shear_y_raw"],
force_unit=forces["force_unit"],
force_calibrated=forces["force_calibrated"],
calibrated_axes=tuple(forces.get("calibrated_axes", ())),
calibration_version=self._calibration_version,
session_id=session,
frame_id=frame_id,
valid=bool(forces.get("valid", True)) and not compute_error,
invalid_reason=invalid_reason,
# A failed chain leaves the previous frame's force fields in
# place; the app must not record them as this frame's data.
stale_forces=bool(compute_error),
raw_available=bool(forces.get("raw_available", True)),
flow=forces["flow"],
depth=forces.get("depth"),
depth=(
forces.get("depth")
if forces.get("depth") is not None
else chain_depth
),
valid_mask=runtime.valid_mask,
)
with self._lock:
self._latest = packet
self._phase = "running"
# Record channel: every packet, regardless of UI refresh rate.
self._emit(packet)
frame_id += 1
saw_frame = True
last_frame_mono = t1
except Exception as exc:
@@ -289,7 +552,7 @@ class FingerAcquisitionWorker:
self._on_error(str(exc))
break
self._close_runtime()
self._close_runtime(session)
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
@@ -0,0 +1,137 @@
"""The data shapes shared by the acquisition workers, the record channel and the UI.
They live here rather than in one of the workers so that neither worker has to
import the other, and so the record channel can describe a packet without
importing a device loop. The workers re-export these names for compatibility.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class FixedCameraMode:
"""The camera geometry a session runs with."""
width: int
height: int
fps: float
config_ref: str
def label(self) -> str:
return f"{self.width}x{self.height} @{self.fps:g}"
@dataclass
class SdkPacket:
"""One acquired frame: the images, the force readout and its provenance."""
raw_frame: np.ndarray
processed_frame: np.ndarray
timestamp: float
fps: float
process_ms: float
#: The rate the *capture source* published frames at, over the same window as
#: ``fps``. ``fps`` is what this loop consumed; when the two disagree, the
#: difference is frames the camera had and the loop did not take.
capture_fps: float = 0.0
#: How many published frames were replaced before anyone consumed them.
capture_dropped: int = 0
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_unit: str = "raw"
force_calibrated: bool = False
#: Which axes actually have a calibration curve loaded. The sensor-level
#: ``force_calibrated`` flag cannot express a partially calibrated device.
calibrated_axes: tuple[str, ...] = ()
session_id: int = 0
#: Monotonic per-session frame index; the join key for video, CSV and
#: calibration records.
frame_id: int = 0
#: Monotonic, session-relative acquisition time. Used for ordering so a wall
#: clock that steps backwards cannot reorder history. ``None`` means the
#: producer did not measure one, and the wall clock is used instead — a
#: default of ``0.0`` would have made every frame look simultaneous.
mono_t: Optional[float] = None
#: Wall clock at hand-off, for metadata only. ``None`` means unmeasured.
wall_t: Optional[float] = None
#: False when the force readout failed. Invalid packets carry NaN forces and
#: must never be averaged into calibration data or written as zeros.
valid: bool = True
invalid_reason: str = ""
#: True when the compute chain failed for this frame. The SDK's force fields
#: then still hold the value computed for an earlier frame, so they must not
#: be recorded as this frame's measurement.
stale_forces: bool = False
#: Content hash of the calibration actually in effect when this frame was
#: acquired; changes mid-session when new coefficients are applied.
calibration_version: str = ""
#: False when the sensor exposes no RAW channels; the raw fields are then
#: NaN rather than a copy of the calibrated values.
raw_available: bool = True
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
"""Resolve a config reference (a file path or a product name) to its geometry."""
ref = str(config_ref).strip()
path = Path(ref)
if path.is_file():
data = json.loads(path.read_text(encoding="utf-8"))
resolved = str(path.resolve())
else:
import orisys
data = orisys.configs.load_config(ref)
resolved = ref
camera = data.get("camera") or {}
raw = camera.get("resolution_raw") or [1920, 1080]
fps = float(camera.get("fps") or 30.0)
return FixedCameraMode(
width=int(raw[0]),
height=int(raw[1]),
fps=fps,
config_ref=resolved,
)
def ensure_bgr(img: np.ndarray) -> np.ndarray:
"""Three-channel BGR view of whatever the sensor hands over."""
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def is_file_source(video_source: str | int) -> bool:
"""True when the source is a file (a replay) rather than a live camera."""
return isinstance(video_source, str)
def orisys_importable() -> tuple[bool, str]:
"""Whether the SDK can be imported, with the reason when it cannot."""
try:
import orisys # noqa: F401
return True, getattr(__import__("orisys"), "__version__", "unknown")
except Exception as exc: # noqa: BLE001
return False, str(exc)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
"""Record-channel fan-out shared by the acquisition workers.
The workers used to expose exactly one consumer of their data — a single
``latest()`` slot polled by a Qt timer. That made recording depend on the UI
refresh rate, and it meant that hiding a panel could stop a recording.
Every worker now also publishes each :class:`~orisys_app_shared.acquisition.sdk_worker.SdkPacket`
to a list of sinks, from the acquisition thread. Sinks are simple callables and
must be cheap and Qt-free; a sink that raises is logged and dropped for that
packet rather than being allowed to kill acquisition.
"""
from __future__ import annotations
import logging
import threading
from collections import deque
from typing import Any, Callable, Iterable
import numpy as np
logger = logging.getLogger(__name__)
#: How many pre-processing frames are kept before a recording starts.
PREROLL_FRAMES = 30
#: Byte ceiling for the same buffer (30 frames of 1080p BGR is already ~190 MB).
PREROLL_BYTES = 192 * 1024 * 1024
class RecordSinkMixin:
"""Mixin adding ``add_sink`` / ``remove_sink`` / ``_emit`` to a worker."""
def _init_sinks(self) -> None:
self._sinks: list[Callable[[Any], None]] = []
self._sinks_lock = threading.Lock()
self._emit_failures = 0
self._init_preroll()
# ------------------------------------------------------------- pre-roll
def _init_preroll(self) -> None:
"""Buffer of the frames captured before recording starts.
The SDK's depth chain is stateful (optical-flow tracker plus temporal
filters), so the first frames of a recording cannot be recomputed offline
without the frames that preceded them. This is that history, bounded by
both a frame count and a byte budget.
"""
self._preroll: deque = deque()
self._preroll_lock = threading.Lock()
self._preroll_bytes = 0
def note_preroll(self, frame) -> None:
"""Remember one pre-processing frame. Called from the acquisition thread."""
if frame is None:
return
block = np.asarray(frame)
if block.size == 0:
return
copy = block.copy() # the sensor reuses its buffer between reads
with self._preroll_lock:
self._preroll.append(copy)
self._preroll_bytes += int(copy.nbytes)
while self._preroll and (
len(self._preroll) > PREROLL_FRAMES
or self._preroll_bytes > PREROLL_BYTES
):
dropped = self._preroll.popleft()
self._preroll_bytes -= int(np.asarray(dropped).nbytes)
def take_preroll(self) -> list:
"""Hand the buffered frames to a recorder and start a fresh buffer."""
with self._preroll_lock:
frames = list(self._preroll)
self._preroll.clear()
self._preroll_bytes = 0
return frames
def preroll_size(self) -> int:
with self._preroll_lock:
return len(self._preroll)
def add_sink(self, sink: Callable[[Any], None]) -> None:
"""Register a record-channel consumer of every packet."""
with self._sinks_lock:
if sink not in self._sinks:
self._sinks.append(sink)
def remove_sink(self, sink: Callable[[Any], None]) -> None:
with self._sinks_lock:
if sink in self._sinks:
self._sinks.remove(sink)
def sinks(self) -> tuple[Callable[[Any], None], ...]:
with self._sinks_lock:
return tuple(self._sinks)
@property
def emit_failures(self) -> int:
"""Number of sink calls that raised — surfaced so it is never silent."""
return self._emit_failures
def _emit(self, packet: Any) -> None:
"""Hand ``packet`` to every sink, from the acquisition thread."""
for sink in self.sinks():
try:
sink(packet)
except Exception: # noqa: BLE001 - one bad consumer must not stop capture
self._emit_failures += 1
logger.exception("Record sink %r failed", sink)
def emit_all(sinks: Iterable[Callable[[Any], None]], packet: Any) -> int:
"""Utility for tests: emit to a plain list of sinks, returning failures."""
failures = 0
for sink in sinks:
try:
sink(packet)
except Exception: # noqa: BLE001
failures += 1
logger.exception("Record sink %r failed", sink)
return failures
+35 -1
View File
@@ -1,4 +1,4 @@
"""Force history and unit helpers."""
"""Force history, session storage, calibration sampling and unit helpers."""
from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS,
@@ -8,19 +8,53 @@ from orisys_app_shared.force.calibration_io import (
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_recorder import (
AxisStats,
CalibrationPoint,
CalibrationRecorder,
)
from orisys_app_shared.force.filters import (
FilterSettings,
ForceFilterChain,
describe_state,
effective_filters,
settings_from_mapping,
)
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
from orisys_app_shared.force.session import (
NA,
SelectionStats,
SessionSample,
SessionSlice,
SessionStore,
summarise,
)
from orisys_app_shared.force.units import DisplayForces, select_display_forces
__all__ = [
"CHANNEL_KEYS",
"NA",
"AxisStats",
"CalSample",
"CalSessionStore",
"CalibrationPoint",
"CalibrationRecorder",
"DisplayForces",
"FilterSettings",
"ForceFilterChain",
"MetricHistory",
"PlotState",
"SelectionStats",
"SessionSample",
"SessionSlice",
"SessionStore",
"build_force_calibration_v2",
"describe_state",
"effective_filters",
"export_force_calibration_v2",
"lowpass_filter",
"meta_from_sensor_json",
"select_display_forces",
"settings_from_mapping",
"summarise",
]
@@ -1,29 +1,32 @@
"""Build and write schema_version-2 force_calibration.json files."""
"""Build and write schema_version-2 force_calibration.json files.
Delegates schema/parse/write to ``orisys.calibration`` so the app and SDK share
one format. ``CalSessionStore`` remains the UI sampling table.
"""
from __future__ import annotations
import json
import math
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Literal
from typing import Any, Iterable, Literal, Mapping
from orisys.calibration import (
CalSample as SdkCalSample,
build as sdk_build,
check as sdk_check,
pipeline_from_sensor_json,
write as sdk_write,
)
ChannelKey = Literal["fn", "fx", "fy"]
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
_CHANNEL_TO_JSON = {
"fn": "normal",
"fx": "shear_x",
"fy": "shear_y",
}
_JSON_RAW_KEY = {
"normal": "fnormal",
"shear_x": "fshearx",
"shear_y": "fsheary",
}
WEIGHT_EPS = 1e-6
#: Channel slot of each axis inside ``Calibration.convert``'s triple.
_CONVERT_SLOT: dict[str, int] = {"fn": 0, "fx": 1, "fy": 2}
@dataclass
@@ -33,7 +36,65 @@ class CalSample:
n: int
std: float = 0.0
def to_sdk(self) -> SdkCalSample:
return SdkCalSample(
weight_n=float(self.weight_n),
raw_mean=float(self.raw_mean),
n=int(self.n),
std=float(self.std),
)
def merge_samples(first: CalSample, second: CalSample) -> CalSample:
"""Combine two observations of one reference load.
The means are n-weighted and the standard deviations are pooled about the
combined mean, so a repeated load ends up with the scatter of every sample
that went into it rather than of whichever one happened to be last.
"""
n_total = int(first.n) + int(second.n)
if n_total <= 0:
return CalSample(
weight_n=float(first.weight_n),
raw_mean=(float(first.raw_mean) + float(second.raw_mean)) / 2.0,
n=0,
std=0.0,
)
mean = (
float(first.raw_mean) * int(first.n) + float(second.raw_mean) * int(second.n)
) / n_total
ss = (int(first.n) - 1) * float(first.std) ** 2
ss += (int(second.n) - 1) * float(second.std) ** 2
ss += int(first.n) * (float(first.raw_mean) - mean) ** 2
ss += int(second.n) * (float(second.raw_mean) - mean) ** 2
var = ss / (n_total - 1) if n_total > 1 else 0.0
return CalSample(
weight_n=float(first.weight_n),
raw_mean=float(mean),
n=n_total,
std=math.sqrt(max(0.0, float(var))),
)
def build_export_model(samples: Mapping[str, list[CalSample]]):
"""Build exactly the model that export and application would use.
Diagnostics must be computed on *this* curve. A separate least-squares fit
can look healthy while the piecewise curve the device actually applies is
the one with the error.
"""
payload = {
key: [sample.to_sdk() for sample in (samples.get(key) or [])]
for key in CHANNEL_KEYS
}
return sdk_build(payload, origin="diagnostics")
def predict_channel(cal, channel: str, raw: float) -> float:
"""Apply ``cal`` to one axis exactly as the runtime conversion does."""
triple = [0.0, 0.0, 0.0]
triple[_CONVERT_SLOT[channel]] = float(raw)
return float(cal.convert(triple[0], triple[1], triple[2])[_CONVERT_SLOT[channel]])
@dataclass
class CalSessionStore:
"""In-memory calibration points for FN / FX / FY."""
@@ -67,6 +128,23 @@ class CalSessionStore:
rows.sort(key=lambda s: s.weight_n)
self.set_channel(key, rows)
def aggregate(self, key: ChannelKey, sample: CalSample) -> None:
"""Add one observation, merging repeated loads instead of overwriting.
The operator may sample the same reference load several times on purpose.
Letting the last sample win threw that evidence away — and with it the
scatter that tells whether a load was actually stable.
"""
rows = self.channel(key)
for i, existing in enumerate(rows):
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
rows[i] = merge_samples(existing, sample)
break
else:
rows.append(sample)
rows.sort(key=lambda s: s.weight_n)
self.set_channel(key, rows)
def remove_at(self, key: ChannelKey, index: int) -> None:
rows = self.channel(key)
if 0 <= index < len(rows):
@@ -78,143 +156,326 @@ class CalSessionStore:
out[key] = [asdict(s) for s in self.channel(key)]
return out
def to_sdk_samples(self) -> dict[str, list[SdkCalSample]]:
return {
key: [s.to_sdk() for s in self.channel(key)]
for key in CHANNEL_KEYS
}
def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
"""Pull stitch / tracker fields from a sensor.json for export meta."""
meta: dict[str, Any] = {
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"config_ref": None,
pipeline = pipeline_from_sensor_json(path)
return {
"stitch_resolution": pipeline.get("stitch_resolution"),
"dis_mode": pipeline.get("dis_mode"),
"hhd_downsample": pipeline.get("hhd_downsample"),
"config_ref": pipeline.get("config_ref"),
}
if path is None:
return meta
p = Path(path)
meta["config_ref"] = str(p)
if not p.is_file():
return meta
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return meta
if not isinstance(data, dict):
return meta
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
stitch = camera.get("resolution_stitch")
if isinstance(stitch, (list, tuple)) and len(stitch) >= 2:
meta["stitch_resolution"] = [int(stitch[0]), int(stitch[1])]
if "dis_mode" in tracker:
try:
meta["dis_mode"] = int(tracker["dis_mode"])
except (TypeError, ValueError):
meta["dis_mode"] = tracker.get("dis_mode")
if "hhd_downsample" in tracker:
try:
meta["hhd_downsample"] = int(tracker["hhd_downsample"])
except (TypeError, ValueError):
meta["hhd_downsample"] = tracker.get("hhd_downsample")
return meta
def _piecewise_channel(
def _meta_blocks(meta: dict[str, Any] | None) -> tuple[dict, dict, dict, str]:
"""Split a flat export-meta dict into the SDK's identity/pipeline/extra."""
meta = dict(meta or {})
identity = {
"sensor_type": meta.get("sensor_type", ""),
"sn": meta.get("sn", ""),
}
pipeline = {
"stitch_resolution": meta.get("stitch_resolution"),
"dis_mode": meta.get("dis_mode"),
"hhd_downsample": meta.get("hhd_downsample"),
"config_ref": meta.get("config_ref"),
}
extra = {
key: value
for key, value in meta.items()
if key
not in (
"sensor_type",
"sn",
"stitch_resolution",
"dis_mode",
"hhd_downsample",
"config_ref",
"samples",
)
}
return identity, pipeline, extra, str(meta.get("origin") or "user")
def aggregate_samples(rows: Iterable[CalSample]) -> list[CalSample]:
"""n-weighted merge of repeated reference loads, sorted by load.
Sampling the same load twice is normal and deliberate; the model has to see
the combined mean and the scatter *between* the samples, not whichever
sample happened to be last.
"""
merged: list[CalSample] = []
for sample in rows:
for index, existing in enumerate(merged):
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
merged[index] = merge_samples(existing, sample)
break
else:
merged.append(sample)
merged.sort(key=lambda item: item.weight_n)
return merged
def training_samples(points) -> tuple[dict[str, list[CalSample]], list, list]:
"""Split points into the training build and the held-out validation set.
This is the **single** filter: a point that is excluded, invalid or has no
reference load never reaches a model, and a validation point is only ever
measured against one. Callers must not re-implement it.
"""
training: list = []
validation: list = []
for point in points or ():
if bool(getattr(point, "excluded", False)):
continue
try:
load = float(point.reference_load_n)
raw_mean = float(point.loaded_stats().mean)
except (AttributeError, TypeError, ValueError):
continue
if not math.isfinite(load) or not math.isfinite(raw_mean):
continue
if bool(getattr(point, "is_validation", False)):
validation.append(point)
else:
training.append(point)
by_channel: dict[str, list[CalSample]] = {key: [] for key in CHANNEL_KEYS}
for point in training:
stats = point.loaded_stats()
std = float(stats.std)
by_channel[str(point.channel)].append(
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(stats.mean),
n=int(stats.n or 0),
std=std if math.isfinite(std) else 0.0,
)
)
return (
{key: aggregate_samples(rows) for key, rows in by_channel.items()},
training,
validation,
)
@dataclass(frozen=True)
class PreparedCalibration:
"""One preparation of a calibration session: samples -> model -> diagnostics.
The diagnostics and the exported document come from *this* object, so the
numbers on screen describe exactly the model that is written to disk. Two
separate preparations (one for the panel, one for the fit) is how the
displayed residual and the applied curve drifted apart in the first place.
"""
model: Any = None
samples: dict[str, list[CalSample]] = field(default_factory=dict)
training: list = field(default_factory=list)
validation: list = field(default_factory=list)
#: ``id(point)`` -> prediction error at that point (predicted - reference).
residuals: dict[int, float] = field(default_factory=dict)
#: ``id(point)`` -> absolute error of a held-out point.
validation_errors: dict[int, float] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
meta: dict = field(default_factory=dict)
@property
def ok(self) -> bool:
return self.model is not None
def channel_samples(self, key: str) -> list[CalSample]:
return list(self.samples.get(key) or [])
def method(self, channel: str):
curve = self.curve(channel)
return str(getattr(curve, "method", "") or "") if curve is not None else None
def breakpoints(self, channel: str) -> int:
curve = self.curve(channel)
return len(getattr(curve, "points", ()) or ()) if curve is not None else 0
def curve(self, channel: str):
if self.model is None:
return None
return self.model.get(channel)
def predict(self, channel: str, raw: float) -> float:
if self.model is None:
return float("nan")
return predict_channel(self.model, channel, raw)
def points_in_fit(self, channel: str) -> int:
return len(self.samples.get(channel) or [])
def validation_points(self, channel: str) -> int:
return sum(1 for point in self.validation if point.channel == channel)
def max_residual(self, channel: str) -> float:
values = [
abs(value)
for point in self.training
if point.channel == channel
for value in [self.residuals.get(id(point))]
if value is not None and math.isfinite(value)
]
return max(values) if values else float("nan")
def max_validation_error(self, channel: str) -> float:
values = [
value
for point in self.validation
if point.channel == channel
for value in [self.validation_errors.get(id(point))]
if value is not None and math.isfinite(value)
]
return max(values) if values else float("nan")
def to_document(self) -> dict[str, Any]:
"""The schema-v2 document for this prepared model."""
if self.model is None:
raise ValueError(
"No complete channels to build (each needs ≥2 points). "
+ "; ".join(self.warnings)
)
return self.model.to_dict()
def prepare_from_samples(
samples: dict[str, list[CalSample]],
*,
json_name: str,
samples: Iterable[CalSample],
signed: bool,
) -> dict[str, Any] | None:
rows = sorted(samples, key=lambda s: abs(float(s.raw_mean)))
if len(rows) < 2:
return None
points = [[abs(float(s.raw_mean)), float(s.weight_n)] for s in rows]
block: dict[str, Any] = {
"method": "piecewise_linear",
"raw_key": _JSON_RAW_KEY[json_name],
"extrapolate": True,
"clamp_output_min": 0.0,
"points": points,
meta: dict[str, Any] | None = None,
) -> PreparedCalibration:
"""Build the model and its diagnostics from prepared training samples.
Repeated loads are merged here too, so a caller that hands in raw (unmerged)
samples still gets the same model as one that hands in points.
"""
merged = {
key: aggregate_samples(samples.get(key) or []) for key in CHANNEL_KEYS
}
if signed:
block["signed"] = True
return block
identity, pipeline, extra, origin = _meta_blocks(meta)
warnings: list[str] = []
model = None
try:
model = sdk_build(
{key: [sample.to_sdk() for sample in rows] for key, rows in merged.items()},
identity=identity,
pipeline=pipeline,
origin=origin,
extra_meta=extra,
)
except Exception as exc: # noqa: BLE001 - surfaced as warnings + no model
warnings.append(str(exc))
if model is not None:
try:
warnings.extend(sdk_check(model).warnings)
except Exception as exc: # noqa: BLE001
warnings.append(f"check failed: {exc}")
for key in CHANNEL_KEYS:
if not model.has(key):
warnings.append(_missing_channel_message(key, merged.get(key) or []))
return PreparedCalibration(
model=model,
samples=merged,
warnings=warnings,
meta=dict(meta or {}),
)
def _missing_channel_message(key: str, rows: list[CalSample]) -> str:
if rows:
return f"{key}: need at least 2 points (have {len(rows)})"
return f"{key}: no samples"
def prepare_calibration(
points,
*,
meta: dict[str, Any] | None = None,
) -> PreparedCalibration:
"""The one entry point: points -> training samples -> model -> diagnostics."""
samples, training, validation = training_samples(points)
prepared = prepare_from_samples(samples, meta=meta)
residuals: dict[int, float] = {}
for point in training:
predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean))
if math.isfinite(predicted):
residuals[id(point)] = predicted - float(point.reference_load_n)
validation_errors: dict[int, float] = {}
for point in validation:
predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean))
if math.isfinite(predicted):
validation_errors[id(point)] = abs(predicted - float(point.reference_load_n))
return replace(
prepared,
training=list(training),
validation=list(validation),
residuals=residuals,
validation_errors=validation_errors,
)
def build_export_model(samples: dict[str, list[CalSample]]):
"""The SDK model for ``samples`` — the same preparation the panel uses."""
return prepare_from_samples(samples).model
def build_force_calibration_v2(
store: CalSessionStore,
source,
*,
meta: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Build schema v2 JSON. Returns (document, skipped_channel_warnings)."""
warnings: list[str] = []
channels: dict[str, Any] = {}
for key, json_name in _CHANNEL_TO_JSON.items():
signed = key in ("fx", "fy")
block = _piecewise_channel(
json_name=json_name,
samples=store.channel(key),
signed=signed,
"""Serialise the prepared model. Returns (document, warnings).
``source`` is a :class:`PreparedCalibration` (the normal path — the document
is exactly the model that was diagnosed) or, for older callers, a
:class:`CalSessionStore`, which is prepared here with ``meta``.
"""
prepared = (
source
if isinstance(source, PreparedCalibration)
else prepare_from_samples(
{key: source.channel(key) for key in CHANNEL_KEYS}, meta=meta
)
if block is None:
if store.channel(key):
warnings.append(
f"{key}: need at least 2 points (have {len(store.channel(key))})"
)
else:
warnings.append(f"{key}: no samples")
continue
channels[json_name] = block
base_meta = {
"date": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"sensor_type": "",
"sn": "",
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"record_duration_s": None,
"config_ref": None,
"samples": store.to_audit_dict(),
}
if meta:
for k, v in meta.items():
if k == "samples":
continue
base_meta[k] = v
if "samples" in meta and meta["samples"] is not None:
base_meta["samples"] = meta["samples"]
doc = {
"schema_version": 2,
"unit": "N",
"channels": channels,
"meta": base_meta,
}
return doc, warnings
)
if prepared.model is None:
# Match the previous API: raise ValueError carrying the channel warnings.
raise ValueError("; ".join(prepared.warnings) or "no complete channels")
return prepared.to_document(), list(prepared.warnings)
def write_force_calibration(
doc: dict[str, Any],
path: str | Path,
) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return path.resolve()
"""Write a calibration dict (already built) to disk."""
from orisys.calibration import parse
cal = parse(doc)
return sdk_write(cal, path, overwrite=True)
def export_force_calibration_v2(
store: CalSessionStore,
source,
*,
export_dir: str | Path,
sensor_type: str,
meta: dict[str, Any] | None = None,
local_bundle_dir: str | Path | None = None,
) -> tuple[Path, Path | None, list[str]]:
"""Write timestamped export and optional local bundle force_calibration.json."""
doc, warnings = build_force_calibration_v2(store, meta=meta)
"""Write timestamped export and optional local bundle force_calibration.json.
``source`` is the prepared calibration (preferred) or a session store.
"""
doc, warnings = build_force_calibration_v2(source, meta=meta)
if not doc.get("channels"):
raise ValueError(
"No complete channels to export (each needs ≥2 points). "
@@ -0,0 +1,445 @@
"""Calibration sampling state machine, independent of any Qt widget.
Two responsibilities are deliberately split across two threads:
``feed(sample)``
Called from the acquisition thread (via the worker's sink list) for **every**
acquired sample. It only appends to a thread-safe buffer, so recording keeps
running when the calibration panel is hidden, when the plots are redrawing
slowly, or when the UI is busy. It never touches Qt.
``tick()``
Called from the UI thread. It evaluates the sampling deadline and turns the
buffered samples into a finished :class:`CalibrationPoint`, invoking the
callbacks on the UI thread where it is safe to touch widgets.
Each point stores all three axes rather than only the loaded one, so crosstalk
between axes is observable from the recorded data. The non-loaded axes carry raw
statistics but **no** reference load: their true load is unknown during a
single-axis calibration, so it is left empty rather than filled with zero.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Optional
import numpy as np
from orisys_app_shared.force.models import (
CHANNEL_KEYS,
MAX_TRACE_POINTS,
NA,
SAMPLE_FIELDS,
STATUS_CANCELLED,
STATUS_FEW_SAMPLES,
STATUS_OK,
AxisStats,
CalibrationPoint,
ChannelKey,
trace_dt,
trace_from,
)
from orisys_app_shared.force.session import SessionSample, summarise
# Re-exported: the models live in ``force.models`` so the session file format can
# describe a point without importing the sampling state machine.
__all__ = [
"AxisStats",
"CalibrationPoint",
"CalibrationRecorder",
"FitResult",
"SAMPLE_FIELDS",
"trace_dt",
"trace_from",
]
@dataclass(frozen=True)
class FitResult:
"""Diagnostics for the model that would actually be exported."""
channel: ChannelKey
n_points: int = 0
max_residual_n: float = NA
validation_error_n: float = NA
validation_points: int = 0
#: Method of the exported curve (``piecewise_linear`` / ``linear``).
method: str = ""
#: Breakpoints in the exported curve, for the UI to describe the model.
breakpoints: int = 0
#: Least-squares summary shown next to the model, never used for residuals.
linear_gain: float = NA
linear_offset: float = NA
@property
def usable(self) -> bool:
return self.n_points >= 2 and bool(self.method)
def as_dict(self) -> dict:
return {
"channel": self.channel,
"method": self.method,
"breakpoints": self.breakpoints,
"linear_gain_n_per_raw": self.linear_gain,
"linear_offset_n": self.linear_offset,
"points_in_fit": self.n_points,
"max_residual_n": self.max_residual_n,
"validation_error_n": self.validation_error_n,
"validation_points": self.validation_points,
}
class CalibrationRecorder:
"""Owns the calibration sampling lifecycle."""
def __init__(
self,
*,
on_finished: Optional[Callable[[CalibrationPoint], None]] = None,
on_cancelled: Optional[Callable[[], None]] = None,
clock: Callable[[], float] = time.perf_counter,
wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._on_finished = on_finished
self._on_cancelled = on_cancelled
self._clock = clock
self._wall_clock = wall_clock
self._lock = threading.Lock()
self._recording = False
self._channel: ChannelKey = "fn"
self._reference_load_n = 0.0
self._direction = "load"
self._note = ""
self._started_perf = 0.0
self._duration_s = 0.0
self._started_at = ""
self._samples: list[tuple[float, float, float, float, float, float, float, bool]] = []
self._rounds: dict[ChannelKey, int] = {}
self.points: list[CalibrationPoint] = []
# ------------------------------------------------------------------ state
@property
def is_recording(self) -> bool:
with self._lock:
return self._recording
@property
def channel(self) -> ChannelKey:
with self._lock:
return self._channel
@property
def requested_duration_s(self) -> float:
with self._lock:
return self._duration_s
def elapsed_s(self) -> float:
with self._lock:
if not self._recording:
return 0.0
return max(0.0, self._clock() - self._started_perf)
def progress(self) -> float:
elapsed = self.elapsed_s()
duration = self.requested_duration_s
if duration <= 0:
return 0.0
return min(1.0, elapsed / duration)
def note(self) -> str:
with self._lock:
return self._note
# ------------------------------------------------------------- lifecycle
def reset_session(self) -> None:
"""Clear per-session state; called when a new acquisition starts."""
with self._lock:
self._recording = False
self._samples = []
self._rounds = {}
self.points = []
def start(
self,
*,
channel: ChannelKey,
reference_load_n: float,
duration_s: float,
direction: str = "load",
note: str = "",
) -> None:
with self._lock:
self._channel = channel if channel in CHANNEL_KEYS else "fn"
self._reference_load_n = float(reference_load_n)
self._duration_s = max(0.05, float(duration_s))
self._direction = str(direction or "load")
self._note = str(note or "")
self._started_perf = self._clock()
self._started_at = self._wall_clock().isoformat(timespec="seconds")
self._samples = []
self._recording = True
def cancel(self) -> None:
with self._lock:
if not self._recording:
return
self._recording = False
self._samples = []
if self._on_cancelled is not None:
self._on_cancelled()
def feed(self, sample: SessionSample) -> None:
"""Buffer one sample. Safe to call from the acquisition thread."""
with self._lock:
if not self._recording:
return
self._samples.append(
(
int(sample.frame_id),
float(sample.t_rel_s),
float(sample.fn_raw),
float(sample.fx_raw),
float(sample.fy_raw),
float(sample.fn_n),
float(sample.fx_n),
float(sample.fy_n),
bool(sample.valid),
)
)
def tick(self) -> Optional[CalibrationPoint]:
"""Finish on the UI thread once the deadline has passed."""
with self._lock:
if not self._recording:
return None
elapsed = self._clock() - self._started_perf
if elapsed < self._duration_s:
return None
return self._finish()
def finish_now(self) -> Optional[CalibrationPoint]:
"""Stop early and keep whatever has been captured so far."""
with self._lock:
if not self._recording:
return None
return self._finish()
# --------------------------------------------------------------- internals
def _finish(self) -> Optional[CalibrationPoint]:
with self._lock:
samples = self._samples
self._samples = []
self._recording = False
channel = self._channel
reference = self._reference_load_n
direction = self._direction
note = self._note
started_at = self._started_at
duration = self._duration_s
ended_at = self._wall_clock().isoformat(timespec="seconds")
actual_duration = 0.0
if samples:
actual_duration = max(0.0, samples[-1][1] - samples[0][1])
block = np.asarray(samples, dtype=np.float64) if samples else np.zeros((0, 9))
valid_mask = block[:, 8] > 0.5 if block.size else np.zeros(0, dtype=bool)
valid_block = block[valid_mask] if block.size else block
def axis_stats(column: int) -> AxisStats:
if valid_block.size == 0:
return AxisStats()
mean, std, count = summarise(valid_block[:, column])
return AxisStats(mean=mean, std=std, n=count)
point = CalibrationPoint(
channel=channel,
reference_load_n=reference,
direction=direction,
round_index=self._next_round(channel),
note=note,
started_at=started_at,
ended_at=ended_at,
duration_s=actual_duration,
requested_duration_s=duration,
n_total=int(block.shape[0]),
n_valid=int(valid_block.shape[0]),
raw={"fn": axis_stats(2), "fx": axis_stats(3), "fy": axis_stats(4)},
newtons={"fn": axis_stats(5), "fx": axis_stats(6), "fy": axis_stats(7)},
status=STATUS_OK if valid_block.shape[0] >= 2 else STATUS_FEW_SAMPLES,
trace=trace_from(valid_block),
trace_dt_s=trace_dt(valid_block),
# Every sample is kept, including the invalid ones: a point that was
# mostly invalid is itself a finding. ``samples`` holds the original
# tuples so ``frame_id`` stays an integer.
samples=list(samples),
)
with self._lock:
self.points.append(point)
if self._on_finished is not None:
self._on_finished(point)
return point
# ------------------------------------------------------------ diagnostics
def all_points(self) -> list[CalibrationPoint]:
"""Snapshot of every recorded point, across channels."""
with self._lock:
return list(self.points)
def _resolve(self, point: CalibrationPoint) -> Optional[CalibrationPoint]:
"""Find the recorded point that is ``point``, by identity.
Callers pass the object they are looking at. An integer row index would
be ambiguous: the panel shows a per-channel filtered list while the
recorder stores every channel in one list, so index 0 of the table is
not index 0 of ``points``.
"""
if point is None:
return None
for candidate in self.points:
if candidate is point:
return candidate
return None
def set_excluded(self, point: CalibrationPoint, excluded: bool) -> bool:
"""Remove (or restore) a point from the fit and from the export."""
with self._lock:
target = self._resolve(point)
if target is None:
return False
target.excluded = bool(excluded)
if excluded:
target.is_validation = False
return True
def set_validation(self, point: CalibrationPoint, validation: bool) -> bool:
"""Hold a point out of the fit and use it to test the fit instead."""
with self._lock:
target = self._resolve(point)
if target is None:
return False
target.is_validation = bool(validation)
if validation:
target.excluded = False
return True
def remove_point(self, point: CalibrationPoint) -> bool:
"""Drop a point entirely (used by "delete row")."""
with self._lock:
target = self._resolve(point)
if target is None:
return False
self.points.remove(target)
return True
def clear_points(self, channel: Optional[ChannelKey] = None) -> int:
"""Drop every point, or every point of one channel. Returns the count."""
with self._lock:
if channel is None:
removed = len(self.points)
self.points = []
return removed
kept = [point for point in self.points if point.channel != channel]
removed = len(self.points) - len(kept)
self.points = kept
return removed
def load_points(self, points, *, replace: bool = True) -> int:
"""Adopt points read back from a saved calibration session.
Reopening a session has to put the operator back exactly where they left
off, so the diagnostics (excluded, validation) travel with the points.
"""
loaded = list(points)
with self._lock:
if replace:
self.points = loaded
else:
self.points.extend(loaded)
for point in loaded:
index = self._rounds.get(point.channel, 0)
self._rounds[point.channel] = max(index, int(point.round_index) + 1)
return len(loaded)
def points_for(self, channel: ChannelKey) -> list[CalibrationPoint]:
with self._lock:
return [point for point in self.points if point.channel == channel]
def prepared(self, *, meta: Optional[dict] = None):
"""The session prepared into one model plus its diagnostics.
Everything that shows or writes a calibration goes through this, so the
panel, the fit summary and the exported file cannot disagree about which
points were used.
"""
from orisys_app_shared.force.calibration_io import prepare_calibration
with self._lock:
points = list(self.points)
return prepare_calibration(points, meta=meta)
def fit(self, channel: ChannelKey, prepared=None) -> FitResult:
"""Diagnose the model that export and application would actually use.
The residual and validation figures used to come from a private
least-squares line while the exported file carried a piecewise curve, so
the numbers on screen did not describe the calibration that would be
applied. Both now come from the one prepared model (see
:func:`~orisys_app_shared.force.calibration_io.prepare_calibration`):
excluded points are dropped, validation points are held out of the build
and only measured, and repeated loads are merged before the build.
"""
if prepared is None:
prepared = self.prepared()
points = self.points_for(channel)
for point in points:
value = prepared.residuals.get(id(point))
point.residual_n = NA if value is None else float(value)
curve = prepared.curve(channel)
n_points = prepared.points_in_fit(channel)
if curve is None or n_points < 2:
return FitResult(channel=channel, n_points=n_points)
raws = np.asarray(
[
float(point.loaded_stats().mean)
for point in prepared.training
if point.channel == channel
],
dtype=float,
)
loads = np.asarray(
[
float(point.reference_load_n)
for point in prepared.training
if point.channel == channel
],
dtype=float,
)
linear_gain = linear_offset = NA
if raws.size >= 2 and not np.allclose(raws, raws[0]):
linear_gain, linear_offset = (float(v) for v in np.polyfit(raws, loads, 1))
return FitResult(
channel=channel,
n_points=n_points,
max_residual_n=prepared.max_residual(channel),
validation_error_n=prepared.max_validation_error(channel),
validation_points=prepared.validation_points(channel),
method=str(getattr(curve, "method", "") or ""),
breakpoints=prepared.breakpoints(channel),
linear_gain=linear_gain,
linear_offset=linear_offset,
)
def _next_round(self, channel: ChannelKey) -> int:
with self._lock:
index = self._rounds.get(channel, 0)
self._rounds[channel] = index + 1
return index
@@ -0,0 +1,284 @@
"""Persist a calibration session: every point and its full three-axis samples.
The exported SDK coefficients are the *result* of a calibration; this file is the
*evidence*. It keeps what the coefficients cannot:
* the complete sample series of every point (frame id, relative time, all three
raw axes, all three newton axes and the validity flag), so a point can be
re-examined even though the diagnostic trace on screen is downsampled;
* the actual duration of each point next to the duration that was requested;
* which points were excluded and which were held out as validation points;
* the direction (load / unload) and note of each point.
The file is written next to the exported coefficients and can be reopened to
inspect the session again.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Optional
from orisys_app_shared.force.models import (
SAMPLE_FIELDS,
AxisStats,
CalibrationPoint,
trace_dt,
trace_from,
)
logger = logging.getLogger(__name__)
#: Format version of the calibration session document.
SESSION_FORMAT_VERSION = "1.0"
SESSION_REQUIRED = ("format", "format_version", "created_at", "points")
POINT_REQUIRED = ("channel", "reference_load_n")
@dataclass
class CalibrationSessionRecord:
"""One reopened calibration session."""
path: Path
format_version: str
created_at: str
meta: dict = field(default_factory=dict)
points: list = field(default_factory=list)
#: What could not be restored exactly (reported, never silently dropped).
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return bool(self.points) and not self.warnings
def identity(self) -> dict:
"""The device this session belongs to.
A calibration record is evidence about *one* device: its sensor type, its
serial number and the configuration it was taken with. Reopening it must
not restamp it with whatever device happens to be connected now.
"""
return {
"sensor_type": str(self.meta.get("sensor_type") or ""),
"sn": str(self.meta.get("sn") or ""),
"config_ref": str(self.meta.get("config_ref") or ""),
"created_at": self.created_at,
}
def describe(self) -> str:
identity = self.identity()
parts = [identity["sensor_type"] or "?"]
if identity["sn"]:
parts.append(f"SN {identity['sn']}")
if identity["config_ref"]:
parts.append(identity["config_ref"])
return " / ".join(parts)
def _jsonable(value: Any) -> Any:
"""NaN/Inf are not valid JSON; they are written as ``null``."""
if isinstance(value, float):
if value != value or value in (float("inf"), float("-inf")):
return None
return value
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
return value
def build_session_document(
points: Iterable,
*,
meta: Optional[dict] = None,
created_at: Optional[str] = None,
) -> dict:
"""Build the document for ``points`` without touching the disk."""
documents = []
for point in points:
entry = _jsonable(point.as_dict())
# The full series is the audit data; the trace stays as a quick preview.
entry["sample_fields"] = list(SAMPLE_FIELDS)
entry["samples"] = [
[None if value != value else value for value in row]
for row in getattr(point, "samples", ())
]
documents.append(entry)
return {
"format": "orisys.calibration_session",
"format_version": SESSION_FORMAT_VERSION,
"created_at": created_at
or datetime.now(timezone.utc).isoformat(timespec="seconds"),
"sample_fields": list(SAMPLE_FIELDS),
"meta": _jsonable(dict(meta or {})),
"points": documents,
}
def session_filename(sensor_type: str, *, stamp: Optional[str] = None) -> str:
when = stamp or datetime.now().strftime("%Y%m%d_%H%M%S")
return f"calibration_session_{sensor_type or 'unknown'}_{when}.json"
def write_calibration_session(
path: str | Path,
points: Iterable,
*,
meta: Optional[dict] = None,
) -> Path:
"""Write the session document. Returns the written path."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
document = build_session_document(points, meta=meta)
target.write_text(
json.dumps(document, indent=2, ensure_ascii=False, allow_nan=False),
encoding="utf-8",
)
return target
def read_calibration_session(path: str | Path) -> CalibrationSessionRecord:
"""Load a session document and rebuild its points.
A file that cannot be trusted must say so: a truncated or foreign document
raises, and a document from a newer format is loaded with a warning instead
of being interpreted as if the layout were known.
"""
target = Path(path)
data = json.loads(target.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"not a calibration session document: {target}")
missing = [key for key in SESSION_REQUIRED if key not in data]
if missing:
raise ValueError(
f"calibration session {target.name} is missing {', '.join(missing)}"
)
if str(data.get("format")) != "orisys.calibration_session":
raise ValueError(f"not a calibration session document: {target}")
warnings: list[str] = []
version = str(data.get("format_version") or "")
if version != SESSION_FORMAT_VERSION:
warnings.append(f"format_version {version or '?'} is not {SESSION_FORMAT_VERSION}")
points = []
for index, raw in enumerate(data.get("points") or []):
if not isinstance(raw, dict):
warnings.append(f"point {index}: not an object")
continue
absent = [key for key in POINT_REQUIRED if key not in raw]
if absent:
warnings.append(f"point {index}: missing {', '.join(absent)}")
continue
points.append(_point_from_dict(raw, CalibrationPoint, AxisStats))
return CalibrationSessionRecord(
path=target,
format_version=version,
created_at=str(data.get("created_at") or ""),
meta=dict(data.get("meta") or {}),
points=points,
warnings=warnings,
)
def _stats_from_dict(raw: Any, AxisStats):
if not isinstance(raw, dict):
return AxisStats()
def _number(key: str, fallback: float) -> float:
value = raw.get(key)
if value is None:
return fallback
try:
return float(value)
except (TypeError, ValueError):
return fallback
return AxisStats(
mean=_number("mean", float("nan")),
std=_number("std", float("nan")),
n=int(raw.get("n") or 0),
)
def _point_from_dict(raw: dict, CalibrationPoint, AxisStats):
point = CalibrationPoint(
channel=str(raw.get("channel") or "fn"),
reference_load_n=float(raw.get("reference_load_n") or 0.0),
direction=str(raw.get("direction") or "load"),
round_index=int(raw.get("round_index") or 0),
note=str(raw.get("note") or ""),
started_at=str(raw.get("started_at") or ""),
ended_at=str(raw.get("ended_at") or ""),
duration_s=float(raw.get("duration_s") or 0.0),
requested_duration_s=float(raw.get("requested_duration_s") or 0.0),
n_total=int(raw.get("n_total") or 0),
n_valid=int(raw.get("n_valid") or 0),
status=str(raw.get("status") or "ok"),
excluded=bool(raw.get("excluded", False)),
is_validation=bool(raw.get("is_validation", False)),
)
point.raw = {
key: _stats_from_dict(value, AxisStats)
for key, value in (raw.get("raw") or {}).items()
}
point.newtons = {
key: _stats_from_dict(value, AxisStats)
for key, value in (raw.get("newtons") or {}).items()
}
point.trace = {
str(key): [float(value) for value in (series or [])]
for key, series in (raw.get("trace") or {}).items()
}
point.residual_n = _optional_float(raw.get("residual_n"))
rows = []
for row in raw.get("samples") or []:
if not isinstance(row, (list, tuple)):
continue
values = [float("nan") if value is None else value for value in row]
rows.append(tuple(values))
point.samples = rows
# The full series is the audit data, so everything a diagnostic view needs is
# rebuilt from it: the per-axis trace, its sample interval and, when the
# stored duration is missing, the real sampling span.
restore_diagnostics(point)
return point
def restore_diagnostics(point) -> None:
"""Rebuild the three-axis trace and the sampling span from the samples."""
if not point.samples:
return
try:
import numpy as np
except Exception: # noqa: BLE001 - a partial install must still reopen points
return
width = max(len(row) for row in point.samples)
padded = [tuple(row) + (float("nan"),) * (width - len(row)) for row in point.samples]
block = np.asarray(padded, dtype=np.float64)
if block.shape[1] < len(SAMPLE_FIELDS):
return
valid = block[:, 8] > 0.5
valid_block = block[valid]
if not point.trace and valid_block.size:
point.trace = trace_from(valid_block)
if not point.trace_dt_s and valid_block.size:
point.trace_dt_s = trace_dt(valid_block)
if not point.duration_s and block.shape[0] > 1:
span = float(block[-1, 1] - block[0, 1])
if span == span and span >= 0.0:
point.duration_s = span
def _optional_float(value: Any) -> float:
if value is None:
return float("nan")
try:
return float(value)
except (TypeError, ValueError):
return float("nan")
@@ -0,0 +1,533 @@
"""The app's filter control surface: what was asked for, and what is running.
The filter *chain* lives in the SDK — ``compute_flow`` applies the flow EMA
before the HHD, and ``_apply_force_filter`` applies the median and the low-pass
*before* the device calibration. Filtering anywhere else would produce numbers no
recording could reproduce, and would leave the unfiltered newtons unrecoverable
(the calibration has already been applied by then). So this module does three
things, and nothing else:
* turn the operator's choice into one ``Sensor.configure(...)`` call
(:meth:`FilterSettings.apply_to`);
* read the **effective** values back from ``Sensor.filters_state()``
(:meth:`FilterSettings.from_filters_state`), so the dialog and the record show
what the device is doing rather than what was typed into it;
* write the settings into the recording metadata (:meth:`FilterSettings.metadata`),
keeping the **measurement** group (``flow_ema_alpha``: it changes the flow, so
two recordings with different values are not comparable) apart from the
**readout** group (median, low-pass: they only smooth the force on its way out).
``ForceFilterChain`` at the bottom is a reference implementation kept for
``tests/test_force_filters.py``, which pins that this app understands the SDK's
chain exactly. The app itself never filters.
"""
from __future__ import annotations
import inspect
import logging
from dataclasses import dataclass, replace
from typing import Any, Iterable, Mapping, Optional
import numpy as np
logger = logging.getLogger(__name__)
#: Channels of the reference chain: three raw counts then the three newtons.
CHANNELS = 6
#: The SDK settings this app drives, in chain order (measurement first). These
#: are the sensor's own *state* keys: the same words appear in ``sensor.json``,
#: ``filters_state()``, ``session.json`` and the preferences, so a reader never
#: has to translate between two vocabularies.
MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
READOUT_KEYS: tuple[str, ...] = (
"force_median_window",
"force_lowpass_cutoff_hz",
"force_lowpass_order",
"force_lowpass_sample_rate_hz",
)
#: State key -> ``Sensor.configure`` parameter. Not the same on both sides: the
#: low-pass pair is ``force_lowpass_*`` in the config/state and ``lowpass_*`` in
#: the call. One table, so "can the SDK take this?" and "what do we pass?" can
#: never disagree.
CONFIGURE_PARAMETERS: dict[str, str] = {
"flow_ema_alpha": "flow_ema_alpha",
"force_median_window": "force_median_window",
"force_lowpass_cutoff_hz": "lowpass_cutoff_hz",
"force_lowpass_order": "lowpass_order",
"force_lowpass_sample_rate_hz": "lowpass_sample_rate_hz",
}
#: Every key above, in chain order — "nothing could be applied" is this tuple.
ALL_KEYS: tuple[str, ...] = tuple(CONFIGURE_PARAMETERS)
#: A sensible cutoff to offer when the operator switches the low-pass on.
SUGGESTED_LOWPASS_HZ = 10.0
#: The presets to offer when the SDK cannot be asked for its own. Kept in step
#: with ``orisys.sensor.FILTER_PRESETS`` by tests/test_force_filters.py; the SDK's
#: table wins whenever it can be imported, because that is the table the device
#: resolves against.
FALLBACK_PRESETS: dict[str, dict[str, Any]] = {
"off": {
"flow_ema_alpha": 1.0,
"force_median_window": 1,
"force_lowpass_cutoff_hz": 0.0,
},
"low_latency": {
"flow_ema_alpha": 1.0,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 0.0,
},
"balanced": {
"flow_ema_alpha": 0.5,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 0.0,
},
"low_noise": {
"flow_ema_alpha": 0.3,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 10.0,
},
}
def available_presets() -> dict[str, dict[str, Any]]:
"""Named presets for the dialog: the SDK's own, or the documented copies."""
fallback = {name: dict(values) for name, values in FALLBACK_PRESETS.items()}
try:
from orisys.sensor import available_filter_presets
except Exception: # noqa: BLE001 - the SDK may not be importable at all
return fallback
try:
presets = available_filter_presets()
except Exception: # noqa: BLE001
return fallback
resolved = {str(name): dict(values) for name, values in dict(presets).items()}
return resolved or fallback
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _sample_rate(value: Any) -> float | str:
"""``"auto"``, or a positive rate in Hz. Anything else is ``"auto"``."""
if isinstance(value, str):
text = value.strip().lower()
return "auto" if text in ("", "auto", "measured") else "auto"
rate = _as_float(value, 0.0)
return rate if rate > 0 else "auto"
@dataclass(frozen=True)
class FilterSettings:
"""The filter settings the operator chose, in the SDK's own vocabulary.
Every stage is switched by its **own value** (``flow_ema_alpha >= 1``,
``median_window <= 1``, ``lowpass_hz <= 0`` all mean "off"), so there is no
second boolean to disagree with the number. Values that cannot be used are
resolved to "off" by :meth:`normalised`, never to a default that filters.
"""
#: Flow-EMA exponent. **Measurement**: it changes the flow, the contact
#: statistics and ``_force_raw``, so two recordings with different values are
#: not comparable.
flow_ema_alpha: float = 1.0
#: Sliding-median window in samples. **Readout**.
median_window: int = 1
#: Low-pass cutoff in Hz. **Readout**.
lowpass_hz: float = 0.0
lowpass_order: int = 2
#: ``"auto"`` follows the measured frame rate (30 fps until measured); a
#: number pins the rate the coefficients were designed for.
sample_rate_hz: float | str = "auto"
#: Display only — draw the unfiltered curves beside the filtered ones. Never
#: sent to the sensor, and never part of what makes two records comparable.
show_unfiltered: bool = False
#: The preset this came from, when it came from one (kept so a record can say
#: "balanced" instead of only the numbers it expanded to).
preset: Optional[str] = None
# ------------------------------------------------------------- resolving
def normalised(self) -> "FilterSettings":
"""Clamp to what the SDK can run, with unusable values meaning "off"."""
alpha = _as_float(self.flow_ema_alpha, 1.0)
if not 0.0 < alpha < 1.0:
alpha = 1.0
window = max(1, _as_int(self.median_window, 1))
cutoff = max(0.0, _as_float(self.lowpass_hz, 0.0))
order = min(6, max(1, _as_int(self.lowpass_order, 2)))
return replace(
self,
flow_ema_alpha=alpha,
median_window=window,
lowpass_hz=cutoff,
lowpass_order=order,
sample_rate_hz=_sample_rate(self.sample_rate_hz),
show_unfiltered=bool(self.show_unfiltered),
)
@property
def flow_ema_on(self) -> bool:
return 0.0 < _as_float(self.flow_ema_alpha, 1.0) < 1.0
@property
def median_on(self) -> bool:
return _as_int(self.median_window, 1) > 1
@property
def lowpass_on(self) -> bool:
return _as_float(self.lowpass_hz, 0.0) > 0.0
def active(self) -> bool:
"""Whether any stage filters at all (an off chain is a pass-through)."""
return bool(self.flow_ema_on or self.median_on or self.lowpass_on)
def as_state(self) -> dict[str, Any]:
"""The settings in the shape ``Sensor.filters_state()`` reports."""
settings = self.normalised()
rate = settings.sample_rate_hz
readout: dict[str, Any] = {
"force_median_window": settings.median_window,
"force_lowpass_cutoff_hz": settings.lowpass_hz,
"force_lowpass_order": settings.lowpass_order,
"force_lowpass_rate_auto": rate == "auto",
}
if rate != "auto":
readout["force_lowpass_sample_rate_hz"] = float(rate)
return {
"measurement": {"flow_ema_alpha": settings.flow_ema_alpha},
"readout": readout,
}
def _values_by_state_key(self) -> dict[str, Any]:
"""This request, keyed the way the SDK's state names things."""
settings = self.normalised()
return {
"flow_ema_alpha": settings.flow_ema_alpha,
"force_median_window": settings.median_window,
"force_lowpass_cutoff_hz": settings.lowpass_hz,
"force_lowpass_order": settings.lowpass_order,
"force_lowpass_sample_rate_hz": settings.sample_rate_hz,
}
def as_dict(self) -> dict[str, Any]:
"""A flat mapping for the preferences file (SDK keys, plus display)."""
settings = self.normalised()
return {
"flow_ema_alpha": settings.flow_ema_alpha,
"force_median_window": settings.median_window,
"force_lowpass_cutoff_hz": settings.lowpass_hz,
"force_lowpass_order": settings.lowpass_order,
"force_lowpass_sample_rate_hz": settings.sample_rate_hz,
"show_unfiltered": settings.show_unfiltered,
"preset": settings.preset,
}
def describe(self) -> str:
"""A short line for the record, upstream stage first."""
return describe_state(self.as_state())
def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]:
"""The cutoff/Nyquist problem as numbers, for a UI to phrase itself.
The SDK drops a low-pass whose cutoff is at or above Nyquist instead of
silently re-tuning it, so the dialog has to say so *before* a session
starts — otherwise the file would claim a filter the device is not
running.
"""
settings = self.normalised()
if not settings.lowpass_on or not sample_rate_hz:
return None
rate = _as_float(sample_rate_hz, 0.0)
nyquist = 0.5 * rate
if nyquist <= 0 or settings.lowpass_hz < nyquist:
return None
return {
"cutoff_hz": float(settings.lowpass_hz),
"nyquist_hz": float(nyquist),
"rate_hz": float(rate),
}
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
"""Why the device would not do what these settings ask (English, for logs)."""
issue = self.nyquist_issue(sample_rate_hz)
if issue is None:
return ()
return (
f"lowpass {issue['cutoff_hz']:g} Hz is at or above Nyquist "
f"({issue['nyquist_hz']:g} Hz at {issue['rate_hz']:g} fps): the device "
"would not filter",
)
# ---------------------------------------------------------- driving the SDK
def unsupported_by(self, sensor: object) -> tuple[str, ...]:
"""The SDK keys this sensor's ``configure()`` does not accept.
Reported rather than dropped: a stage the operator switched on but the
installed SDK cannot run is exactly the disagreement that ends up in a
recorded file. Older SDKs (before 0.5.2) have no ``flow_ema_alpha`` and
no median, and their flow EMA is not switchable at all.
"""
try:
parameters = inspect.signature(sensor.configure).parameters
except (AttributeError, TypeError, ValueError): # pragma: no cover - exotic fake
return ALL_KEYS
return tuple(
key
for key, parameter in CONFIGURE_PARAMETERS.items()
if parameter not in parameters
)
def configure_kwargs(self, skip: Iterable[str] = ()) -> dict[str, Any]:
"""The ``Sensor.configure(...)`` call these settings amount to."""
skipped = set(skip)
return {
CONFIGURE_PARAMETERS[key]: value
for key, value in self._values_by_state_key().items()
if key not in skipped
}
def apply_to(self, sensor: object) -> tuple[str, ...]:
"""Push these settings into the device; return what it could not take.
Called from the acquisition thread right after the sensor opens, so a
session is filtered the way the operator asked for from its first frame.
"""
unsupported = self.unsupported_by(sensor)
if unsupported:
logger.warning(
"The installed orisys SDK cannot apply %s; the effective filter "
"state is in Sensor.filters_state()",
", ".join(unsupported),
)
sensor.configure(**self.configure_kwargs(unsupported))
return unsupported
# ------------------------------------------------------------ reading back
@classmethod
def from_filters_state(
cls, state: Mapping[str, Any], *, show_unfiltered: bool = False
) -> "FilterSettings":
"""The settings a device reports as in effect.
This is what the dialog displays and what a record stores: a config file
or a request can say one thing while the running device does another.
"""
measurement = dict(state.get("measurement") or {})
readout = dict(state.get("readout") or {})
rate: Any = "auto"
if not readout.get("force_lowpass_rate_auto", True):
rate = readout.get("force_lowpass_sample_rate_hz", "auto")
return cls(
flow_ema_alpha=_as_float(measurement.get("flow_ema_alpha"), 1.0),
median_window=_as_int(readout.get("force_median_window"), 1),
lowpass_hz=_as_float(readout.get("force_lowpass_cutoff_hz"), 0.0),
lowpass_order=_as_int(readout.get("force_lowpass_order"), 2),
sample_rate_hz=rate,
show_unfiltered=bool(show_unfiltered),
preset=state.get("preset"),
).normalised()
def metadata(self, sensor_state: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
"""The ``filters`` block for ``session.json`` / a recording header.
With a sensor state this records what the device was doing
(``source: sensor.filters_state``); without one it records what the app
asked for and says so, so the two are never mistaken for each other.
"""
if sensor_state:
measurement = dict(sensor_state.get("measurement") or {})
readout = dict(sensor_state.get("readout") or {})
preset = sensor_state.get("preset")
source = str(sensor_state.get("source") or "sensor.filters_state")
describe = describe_state(sensor_state)
else:
state = self.as_state()
measurement = state["measurement"]
readout = state["readout"]
preset = self.normalised().preset
source = "app.FilterSettings"
describe = describe_state(state)
block: dict[str, Any] = {
"measurement": measurement,
"readout": readout,
"describe": describe,
"source": source,
"show_unfiltered": bool(self.show_unfiltered),
}
if preset:
block["preset"] = str(preset)
return block
def describe_state(state: Mapping[str, Any]) -> str:
"""``none`` / ``flowEMA(0.5) + median(3) + lowpass(10Hz,order2)``.
Written upstream-first, which is also the order the stages run and the order
the two groups are listed in: the measurement stage first.
"""
measurement = dict(state.get("measurement") or {})
readout = dict(state.get("readout") or {})
parts: list[str] = []
alpha = _as_float(measurement.get("flow_ema_alpha"), 1.0)
if 0.0 < alpha < 1.0:
parts.append(f"flowEMA({alpha:g})")
window = _as_int(readout.get("force_median_window"), 1)
if window > 1:
parts.append(f"median({window})")
cutoff = _as_float(readout.get("force_lowpass_cutoff_hz"), 0.0)
if cutoff > 0:
order = _as_int(readout.get("force_lowpass_order"), 2)
parts.append(f"lowpass({cutoff:g}Hz,order{order})")
return " + ".join(parts) if parts else "none"
def settings_from_mapping(data: Optional[Mapping[str, Any]]) -> FilterSettings:
"""Build settings from a preferences/metadata mapping, with defaults."""
source = dict(data or {})
base = FilterSettings()
return FilterSettings(
flow_ema_alpha=source.get("flow_ema_alpha", base.flow_ema_alpha),
median_window=source.get("force_median_window", base.median_window),
lowpass_hz=source.get("force_lowpass_cutoff_hz", base.lowpass_hz),
lowpass_order=source.get("force_lowpass_order", base.lowpass_order),
sample_rate_hz=source.get("force_lowpass_sample_rate_hz", base.sample_rate_hz),
show_unfiltered=source.get("show_unfiltered", base.show_unfiltered),
preset=source.get("preset", base.preset),
).normalised()
def effective_filters(sensor: object) -> dict[str, Any]:
"""What a sensor reports as in effect, for a record or for the dialog.
``{}`` means the question could not be asked — an SDK older than 0.5.2 has no
``filters_state()`` — which is different from "nothing is filtering" and has
to stay different in the record.
"""
reader = getattr(sensor, "filters_state", None)
if not callable(reader):
return {}
try:
state = reader()
except Exception: # noqa: BLE001 - metadata must never break a session
logger.exception("Could not read the sensor's filter state")
return {}
return dict(state) if isinstance(state, Mapping) else {}
# ---------------------------------------------------------------------------
# Reference implementation: kept so the tests can prove this app understands the
# SDK's chain exactly. Nothing in the app calls it.
# ---------------------------------------------------------------------------
class ForceFilterChain:
"""The median + low-pass chain the SDK runs, reimplemented for comparison.
Both stages are causal and per-sample, because acquisition cannot look
ahead: the median removes single-sample spikes without smearing a step, and
the low-pass is a Butterworth IIR with persistent state (``sosfilt`` with
``zi``). Order is median then low-pass, so a spike cannot ring the IIR.
"""
def __init__(self, settings: Optional[FilterSettings] = None) -> None:
self._settings = (settings or FilterSettings()).normalised()
self._median: list[list[float]] = [[] for _ in range(CHANNELS)]
self._sos = None
#: One IIR state per channel: the six readings are independent signals.
self._zi: Optional[list[np.ndarray]] = None
self._build()
# ------------------------------------------------------------------ setup
def _build(self) -> None:
self._sos = None
self._zi = None
settings = self._settings
if not settings.lowpass_on:
return
from scipy.signal import butter
rate = _as_float(settings.sample_rate_hz, 30.0)
nyquist = 0.5 * rate
cutoff = min(float(settings.lowpass_hz), nyquist * 0.95)
if cutoff <= 0:
return
self._sos = butter(
settings.lowpass_order,
cutoff / nyquist,
btype="low",
output="sos",
)
self._zi = None
def configure(self, settings: FilterSettings) -> None:
"""Re-tune the chain; the caller resets the stream when it does."""
self._settings = settings.normalised()
self._median = [[] for _ in range(CHANNELS)]
self._build()
def settings(self) -> FilterSettings:
return self._settings
@property
def active(self) -> bool:
return self._settings.active()
def reset(self, first: Optional[Iterable[float]] = None) -> None:
"""Drop the filter memory (a new session must not inherit the old one)."""
self._median = [[] for _ in range(CHANNELS)]
self._zi = None
if first is not None and self._sos is not None:
# Start each IIR at its first sample's steady state instead of at
# zero: a step from 0 to a real reading otherwise rings for a few
# samples, which would show up as a transient at session start.
from scipy.signal import sosfilt_zi
values = np.asarray(list(first), dtype=np.float64).reshape(-1)
if values.size == CHANNELS:
steady = sosfilt_zi(self._sos)
self._zi = [steady * float(value) for value in values]
# ----------------------------------------------------------------- filter
def process(self, values) -> np.ndarray:
"""Filter one 6-channel sample: three raw counts then three newtons."""
sample = np.asarray(list(values), dtype=np.float64).reshape(-1)
if sample.size != CHANNELS:
raise ValueError(f"expected {CHANNELS} channels, got {sample.size}")
if not self.active:
return sample.copy()
settings = self._settings
out = sample.copy()
if settings.median_on:
window = int(settings.median_window)
for index in range(CHANNELS):
series = self._median[index]
series.append(float(sample[index]))
if len(series) > window:
del series[0]
out[index] = float(np.median(series))
if self._sos is not None:
from scipy.signal import sosfilt
if self._zi is None:
self.reset(out)
states = self._zi if self._zi is not None else [None] * CHANNELS
for index in range(CHANNELS):
filtered, states[index] = sosfilt(
self._sos, np.array([out[index]]), zi=states[index]
)
out[index] = float(filtered[0])
self._zi = states
return out
+96 -22
View File
@@ -1,12 +1,20 @@
"""Rolling force history with optional display low-pass."""
"""Rolling force history with optional display low-pass.
Uses a time-windowed buffer (keep samples in ``[t_end - window_sec, t_end]``)
with a headroom cap so high FPS still covers the visible X range after scroll.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Optional
import numpy as np
DEFAULT_MAX_FPS = 90.0
HEADROOM = 1.5
@dataclass
class PlotState:
@@ -41,8 +49,8 @@ def lowpass_filter(
return y.copy()
def _npoints_for_window(window_sec: float, sample_rate_hz: float) -> int:
return max(2, int(round(float(window_sec) * float(sample_rate_hz))))
def _max_points_for_window(window_sec: float, max_fps: float) -> int:
return max(2, int(math.ceil(float(window_sec) * float(max_fps) * HEADROOM)))
class MetricHistory:
@@ -55,35 +63,89 @@ class MetricHistory:
sample_rate_hz: float | None = None,
lowpass_cutoff_hz: float = 0.0,
y_axis_min_span: float = 0.5,
max_fps: float = DEFAULT_MAX_FPS,
):
self.channels = int(channels)
self.window_sec = float(window_sec)
self.max_fps = max(1.0, float(max_fps))
self.sample_rate_hz = (
float(sample_rate_hz)
if sample_rate_hz is not None
else (30.0 if self.window_sec <= 0 else 30.0)
float(sample_rate_hz) if sample_rate_hz is not None else 30.0
)
# ``npoints`` overrides max capacity (tests / callers); else headroom cap.
if npoints is None:
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
self.max_points = _max_points_for_window(self.window_sec, self.max_fps)
else:
self.npoints = int(npoints)
self.max_points = max(2, int(npoints))
# Back-compat alias used by older callers.
self.npoints = self.max_points
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self._capacity = self.max_points
self.reset()
def set_window_sec(self, window_sec: float) -> None:
self.window_sec = max(0.1, float(window_sec))
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
self.reset()
self.max_points = _max_points_for_window(self.window_sec, self.max_fps)
self.npoints = self.max_points
self._ensure_capacity(self.max_points)
self._prune()
self._y_range_smooth = [None for _ in range(self.channels)]
def reset(self) -> None:
self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64)
self._capacity = max(self.max_points, 2)
self._data = np.full(
(self._capacity, 1 + self.channels), np.nan, dtype=np.float64
)
self.sample_count = 0
self._t0: Optional[float] = None
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
None for _ in range(self.channels)
]
def clear(self, *, keep_origin: bool = True) -> None:
"""Drop every sample.
Unlike :meth:`reset` this keeps the time origin by default, so a buffer
rebuilt after a display-unit switch does not restart the X axis at zero.
"""
origin = self._t0 if keep_origin else None
self.reset()
self._t0 = origin
def _ensure_capacity(self, needed: int) -> None:
needed = max(needed, self.max_points, 2)
if needed <= self._capacity:
return
new_cap = max(needed, self._capacity * 2)
new_data = np.full(
(new_cap, 1 + self.channels), np.nan, dtype=np.float64
)
if self.sample_count > 0:
new_data[: self.sample_count] = self._data[: self.sample_count]
self._data = new_data
self._capacity = new_cap
def _prune(self) -> None:
"""Drop samples older than the time window, then enforce max_points."""
n = self.sample_count
if n == 0:
return
t_end = float(self._data[n - 1, 0])
cutoff = t_end - self.window_sec
times = self._data[:n, 0]
# First index with t >= cutoff
keep_from = int(np.searchsorted(times, cutoff, side="left"))
if keep_from > 0:
remaining = n - keep_from
self._data[:remaining] = self._data[keep_from:n]
self.sample_count = remaining
n = remaining
if n > self.max_points:
drop = n - self.max_points
remaining = n - drop
self._data[:remaining] = self._data[drop:n]
self.sample_count = remaining
def push(self, timestamp: float, *values: float) -> None:
if len(values) != self.channels:
raise ValueError(f"Expected {self.channels} values, got {len(values)}")
@@ -91,12 +153,11 @@ class MetricHistory:
self._t0 = float(timestamp)
t = float(timestamp) - self._t0
row = (t, *map(float, values))
if self.sample_count < self.npoints:
self._data[self.sample_count, :] = row
self.sample_count += 1
return
self._data[:-1, :] = self._data[1:, :]
self._data[-1, :] = row
if self.sample_count >= self._capacity:
self._ensure_capacity(self.sample_count + 1)
self._data[self.sample_count, :] = row
self.sample_count += 1
self._prune()
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
if self._y_range_smooth[channel] is None:
@@ -122,6 +183,24 @@ class MetricHistory:
block = self._data[:n]
times = block[:, 0].copy()
values = block[:, 1:].copy()
t_end = float(times[-1])
if t_end >= self.window_sec:
x_range = (t_end - self.window_sec, t_end)
# Keep setData lean: only points inside the visible window.
mask = times >= x_range[0]
times = times[mask]
values = values[mask]
# Discrete samples usually start slightly after the left edge;
# prepend a hold point so the curve spans the full X range.
if times.size > 0 and float(times[0]) > x_range[0]:
times = np.concatenate(([x_range[0]], times))
values = np.vstack([values[0:1], values])
else:
x_range = (0.0, self.window_sec)
if times.size == 0:
return None
if self.lowpass_cutoff_hz > 0:
for i in range(self.channels):
values[:, i] = lowpass_filter(
@@ -129,11 +208,6 @@ class MetricHistory:
self.lowpass_cutoff_hz,
sample_rate_hz=self.sample_rate_hz,
)
t_end = float(times[-1])
if t_end >= self.window_sec:
x_range = (t_end - self.window_sec, t_end)
else:
x_range = (0.0, self.window_sec)
y_ranges = []
for i in range(self.channels):
col = values[:, i]
@@ -0,0 +1,170 @@
"""The calibration data shapes, independent of sampling and of file storage.
They live here so the sampling state machine and the session file format can both
describe a calibration point without importing each other. ``calibration_recorder``
and ``calibration_session`` re-export these names for compatibility.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
import numpy as np
#: Stands in for "this value is not available".
NA = float("nan")
ChannelKey = Literal["fn", "fx", "fy"]
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
STATUS_OK = "ok"
STATUS_FEW_SAMPLES = "few_samples"
STATUS_CANCELLED = "cancelled"
#: Columns of one audit sample row, in order. A calibration record is only
#: auditable if this layout is fixed: it is what a reopened point is rebuilt from.
SAMPLE_FIELDS = (
"frame_id",
"t_rel_s",
"fn_raw",
"fx_raw",
"fy_raw",
"fn_n",
"fx_n",
"fy_n",
"valid",
)
#: Index of each sample column, so callers do not hard-code positions.
SAMPLE_INDEX = {name: index for index, name in enumerate(SAMPLE_FIELDS)}
#: Cap on stored trace samples per axis; peaks are preserved when reducing.
MAX_TRACE_POINTS = 400
@dataclass(frozen=True)
class AxisStats:
"""Mean / standard deviation / valid count for one axis."""
mean: float = NA
std: float = NA
n: int = 0
def as_dict(self) -> dict:
return {"mean": self.mean, "std": self.std, "n": self.n}
@dataclass
class CalibrationPoint:
"""One recorded calibration point, with all three axes captured."""
channel: ChannelKey
reference_load_n: float
direction: str = "load"
round_index: int = 0
note: str = ""
started_at: str = ""
ended_at: str = ""
duration_s: float = 0.0
requested_duration_s: float = 0.0
n_total: int = 0
n_valid: int = 0
raw: dict[str, AxisStats] = field(default_factory=dict)
newtons: dict[str, AxisStats] = field(default_factory=dict)
status: str = STATUS_OK
#: Downsampled per-axis raw trace, so a row can be inspected after the fact.
trace: dict[str, list[float]] = field(default_factory=dict)
trace_dt_s: float = 0.0
#: **Audit** series: every sample of the point, in :data:`SAMPLE_FIELDS` order.
#: The trace above is a preview and must never be the only surviving copy.
samples: list[tuple] = field(default_factory=list)
#: Diagnostics, all operator-controlled. Excluded points leave the fit;
#: validation points are held out of it and used to test it.
excluded: bool = False
is_validation: bool = False
#: Filled in by :meth:`CalibrationRecorder.fit`.
residual_n: float = NA
def loaded_stats(self) -> AxisStats:
return self.raw.get(self.channel, AxisStats())
def axis_mean(self, channel: str) -> float:
stats = self.raw.get(channel)
return stats.mean if stats is not None else NA
def state_key(self) -> str:
if self.excluded:
return "cal.state_excluded"
if self.is_validation:
return "cal.state_validation"
return "cal.state_ok"
def as_dict(self) -> dict:
return {
"channel": self.channel,
"reference_load_n": self.reference_load_n,
# Only the loaded axis has a known reference load; the other two are
# intentionally absent rather than zero.
"direction": self.direction,
"round_index": self.round_index,
"note": self.note,
"started_at": self.started_at,
"ended_at": self.ended_at,
"duration_s": self.duration_s,
"requested_duration_s": self.requested_duration_s,
"n_total": self.n_total,
"n_valid": self.n_valid,
"status": self.status,
"excluded": self.excluded,
"is_validation": self.is_validation,
"residual_n": self.residual_n,
"raw": {key: stats.as_dict() for key, stats in self.raw.items()},
"newtons": {key: stats.as_dict() for key, stats in self.newtons.items()},
# The audit series is part of the point's identity, not a UI extra:
# without it the only surviving copy of the samples is the
# downsampled trace.
"sample_fields": list(SAMPLE_FIELDS),
"n_samples": len(self.samples),
"samples": [list(row) for row in self.samples],
}
def trace_from(valid_block: np.ndarray) -> dict[str, list[float]]:
"""Downsampled raw trace per axis, bounded by ``MAX_TRACE_POINTS``.
One downsampling rule, whether the point was just recorded or read back from
a saved session.
"""
if valid_block.size == 0:
return {}
traces: dict[str, list[float]] = {}
for key, column in (("fn", 2), ("fx", 3), ("fy", 4)):
values = valid_block[:, column]
values = values[np.isfinite(values)]
if values.size == 0:
continue
if values.size > MAX_TRACE_POINTS:
# Keep the shape of the trace, including its extremes.
edges = np.linspace(0, values.size, MAX_TRACE_POINTS + 1, dtype=int)
reduced = [
values[start:max(start + 1, stop)]
for start, stop in zip(edges[:-1], edges[1:])
]
values = np.asarray(
[
bucket[int(np.argmax(np.abs(bucket)))]
for bucket in reduced
if bucket.size
]
)
traces[key] = [float(value) for value in values]
return traces
def trace_dt(valid_block: np.ndarray) -> float:
"""The sample interval of a trace, from the block it was taken from."""
if valid_block.shape[0] < 2:
return 0.0
span = float(valid_block[-1, 1] - valid_block[0, 1])
return span / max(1, valid_block.shape[0] - 1)
@@ -0,0 +1,513 @@
"""Full-session force sample store.
The visible plot buffer (:class:`~orisys_app_shared.force.history.MetricHistory`)
deliberately drops every sample outside its time window, so it can never be the
source of truth for an export, a calibration point or an audit trail.
This store keeps the whole session. It is fed from the acquisition thread via
the worker's sink list, so what gets recorded does not depend on the UI refresh
rate, on which panel happens to be visible, or on how fast the plots redraw.
Design notes
------------
* Samples are appended into fixed-size numpy blocks, so ``append`` is O(1) and
memory stays bounded. When ``max_samples`` is reached the oldest block is
released and ``dropped`` is incremented — callers must surface that counter
rather than let data vanish silently.
* Both the raw and the calibrated triplet are stored per sample, so switching
the display unit never has to reinterpret old data.
* Invalid samples (a failed readout) are stored with ``valid=False`` and NaN
values. They are never silently rewritten to zero, and consumers that build
calibration points or fits must filter on ``valid``.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, replace
from typing import Iterable, Iterator, Optional
import numpy as np
from orisys_app_shared.record.sample import plottable_axis_mask
#: Stands in for "this value is not available / not valid".
NA = float("nan")
DEFAULT_BLOCK_SIZE = 8192
#: ~4.5 h at 30 Hz of three raw + three calibrated float64 channels.
DEFAULT_MAX_SAMPLES = 500_000
# Column layout of the packed store.
_COL_FRAME_ID = 0
_COL_T = 1
_COL_FN_RAW = 2
_COL_FX_RAW = 3
_COL_FY_RAW = 4
_COL_FN_N = 5
_COL_FX_N = 6
_COL_FY_N = 7
_COL_VALID = 8
_COLUMNS = 9
@dataclass(frozen=True)
class SessionSample:
"""One acquisition step, as handed to the record channel."""
frame_id: int
t_rel_s: float
fn_raw: float = NA
fx_raw: float = NA
fy_raw: float = NA
fn_n: float = NA
fx_n: float = NA
fy_n: float = NA
valid: bool = True
calibration_id: str = ""
error: str = ""
#: Axes that actually have a calibration curve. An axis outside this set has
#: no newton value, which the UI shows as a dash rather than as a number.
calibrated_axes: tuple[str, ...] = ()
@classmethod
def from_record(cls, record) -> "SessionSample":
"""Build from the canonical :class:`RecordSample`.
The record channel has one conversion from a packet (see
``record.sample.to_record_sample``); this only renames the fields so the
store keeps its own shape.
"""
return cls(
frame_id=int(record.frame_id),
t_rel_s=float(record.mono_t),
fn_raw=float(record.fn_raw),
fx_raw=float(record.fx_raw),
fy_raw=float(record.fy_raw),
fn_n=float(record.fn_n),
fx_n=float(record.fx_n),
fy_n=float(record.fy_n),
valid=bool(record.valid),
calibration_id=str(record.calibration_version or ""),
error=str(record.invalid_reason or ""),
calibrated_axes=tuple(record.calibrated_axes or ()),
)
def raw_triplet(self) -> tuple[float, float, float]:
return (self.fn_raw, self.fx_raw, self.fy_raw)
def newton_triplet(self) -> tuple[float, float, float]:
return (self.fn_n, self.fx_n, self.fy_n)
def has_newton(self) -> bool:
return all(np.isfinite(v) for v in self.newton_triplet())
def has_raw(self) -> bool:
return all(np.isfinite(v) for v in self.raw_triplet())
@dataclass(frozen=True)
class SessionSlice:
"""A copy of a time range, ready for plotting or analysis."""
times: np.ndarray
raw: np.ndarray
newtons: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
truncated: bool = False
def __len__(self) -> int:
return int(self.times.size)
def value_for_unit(self, unit: str) -> np.ndarray:
"""Return the ``(n, 3)`` block matching a display unit."""
if str(unit).upper().startswith("N"):
return self.newtons
return self.raw
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""``(times, values, mask)`` for drawing, under one shared rule.
``mask`` is per axis ``(n, 3)``: a slice is drawn only where the sample is
**valid** *and that axis* is finite. Filtering on finiteness alone let a
stale number — left behind by a failed compute step — reappear in one view
after another view had already excluded it, and an all-axes rule hid a
valid FN whenever the device had no shear calibration.
"""
values = self.value_for_unit(unit)
return self.times, values, plottable_axis_mask(values, self.valid)
def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]:
"""``(times, values)`` for one channel, with the other axes ignored."""
values = self.value_for_unit(unit)
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
@dataclass(frozen=True)
class SelectionStats:
"""What the browser shows for the current selection."""
duration_s: float = 0.0
samples: int = 0
peaks: tuple[float, float, float] = (NA, NA, NA)
means: tuple[float, float, float] = (NA, NA, NA)
stds: tuple[float, float, float] = (NA, NA, NA)
def _decimate_preserving_peaks(rows: np.ndarray, max_points: int, unit: str = "raw") -> np.ndarray:
"""Reduce ``rows`` to about ``max_points`` while keeping every spike.
Each bucket contributes the extreme sample of **every plotted axis** in the
active unit, deduplicated and put back in time order. Selecting on one axis
only (the old behaviour) erased a shear spike completely: with FN flat and a
single FX point at 999, the decimated data reported an FX maximum of 0.
"""
total = rows.shape[0]
if max_points < 4 or total <= max_points:
return rows
columns = (
(_COL_FN_N, _COL_FX_N, _COL_FY_N)
if str(unit).upper().startswith("N")
else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
)
bucket = max(2, total // max(2, max_points // (2 * len(columns))))
usable = (total // bucket) * bucket
head = rows[:usable].reshape(-1, bucket, rows.shape[1])
tail = rows[usable:]
buckets = head.shape[0]
index = np.arange(buckets)
picks: list[np.ndarray] = []
for column in columns:
values = head[:, :, column]
finite = np.isfinite(values)
ordered = np.where(finite, values, -np.inf)
low = np.argmin(ordered, axis=1)
high = np.argmax(ordered, axis=1)
picks.append(head[index, np.minimum(low, high)])
picks.append(head[index, np.maximum(low, high)])
picked = np.concatenate(picks, axis=0)
# De-duplicate (the same row is often the extreme for several axes) and
# restore time order. Sorting the *rows* by frame id is what restores the
# order; sorting the indices of a concatenation of per-axis picks does not,
# because the picks restart from the first bucket for every axis.
picked = picked[np.argsort(picked[:, _COL_FRAME_ID], kind="stable")]
keep = np.ones(picked.shape[0], dtype=bool)
if picked.shape[0] > 1:
keep[1:] = np.diff(picked[:, _COL_FRAME_ID]) != 0
picked = picked[keep]
if tail.shape[0]:
picked = np.vstack([picked, tail])
return picked
class SessionStore:
"""Thread-safe, memory-bounded, whole-session sample store."""
def __init__(
self,
*,
max_samples: int = DEFAULT_MAX_SAMPLES,
block_size: int = DEFAULT_BLOCK_SIZE,
) -> None:
self._lock = threading.RLock()
self.max_samples = max(2, int(max_samples))
self.block_size = max(16, int(block_size))
self._blocks: list[np.ndarray] = []
self._count = 0
self._dropped = 0
self._session_id = 0
self._calibration_id = ""
self._t0: Optional[float] = None
self._last_t: Optional[float] = None
self._invalid = 0
# ------------------------------------------------------------- lifecycle
def reset(self, *, session_id: int = 0, calibration_id: str = "") -> None:
with self._lock:
self._blocks = []
self._count = 0
self._dropped = 0
self._invalid = 0
self._session_id = int(session_id)
self._calibration_id = str(calibration_id or "")
self._t0 = None
self._last_t = None
def set_calibration_id(self, calibration_id: str) -> None:
with self._lock:
self._calibration_id = str(calibration_id or "")
# ---------------------------------------------------------------- writing
def append(self, sample: SessionSample) -> None:
"""Record one sample. Never raises on bad values — it marks them invalid."""
with self._lock:
if self._t0 is None:
self._t0 = float(sample.t_rel_s)
t = float(sample.t_rel_s) - self._t0
valid = 1.0 if sample.valid else 0.0
if not sample.valid:
self._invalid += 1
row = (
int(sample.frame_id),
t,
float(sample.fn_raw),
float(sample.fx_raw),
float(sample.fy_raw),
float(sample.fn_n),
float(sample.fx_n),
float(sample.fy_n),
valid,
)
self._write_row(row)
self._last_t = float(sample.t_rel_s)
def _write_row(self, row: tuple) -> None:
if self._count % self.block_size == 0:
self._blocks.append(
np.full((self.block_size, _COLUMNS), np.nan, dtype=np.float64)
)
block = self._blocks[-1]
offset = self._count % self.block_size
block[offset, :] = row
self._count += 1
if self._count > self.max_samples:
self._release_oldest()
def _release_oldest(self) -> None:
"""Drop whole blocks from the front so memory stays bounded.
The block currently being written is never released, so the most recent
``block_size`` samples always survive.
"""
while self._count > self.max_samples and len(self._blocks) > 1:
self._blocks.pop(0)
self._count -= self.block_size
self._dropped += self.block_size
if self._count < 0:
self._count = 0
# ---------------------------------------------------------------- reading
@property
def count(self) -> int:
with self._lock:
return self._count
@property
def dropped(self) -> int:
"""Samples evicted by the memory bound — must be surfaced, never hidden."""
with self._lock:
return self._dropped
@property
def invalid(self) -> int:
with self._lock:
return self._invalid
@property
def session_id(self) -> int:
with self._lock:
return self._session_id
@property
def calibration_id(self) -> str:
with self._lock:
return self._calibration_id
@property
def duration_s(self) -> float:
with self._lock:
if self._last_t is None or self._t0 is None:
return 0.0
return float(self._last_t - self._t0)
def origin_time(self) -> float:
"""Absolute acquisition timestamp that ``t_rel_s == 0`` refers to."""
with self._lock:
return float(self._t0) if self._t0 is not None else 0.0
def _packed(self) -> np.ndarray:
if self._count == 0:
return np.empty((0, _COLUMNS), dtype=np.float64)
blocks: list[np.ndarray] = []
remaining = self._count
for block in self._blocks:
take = min(remaining, block.shape[0])
blocks.append(block[:take])
remaining -= take
if remaining <= 0:
break
return np.vstack(blocks) if len(blocks) > 1 else blocks[0]
def data(self) -> SessionSlice:
"""The whole stored session (used by export)."""
with self._lock:
return self._slice_from(self._packed())
def tail(self, seconds: float) -> SessionSlice:
"""The last ``seconds`` of the session."""
with self._lock:
packed = self._packed()
if packed.shape[0] == 0:
return self._slice_from(packed)
t_end = float(packed[-1, _COL_T])
mask = packed[:, _COL_T] >= (t_end - max(0.0, float(seconds)))
return self._slice_from(packed[mask])
def window(self, t0: float, t1: float) -> SessionSlice:
with self._lock:
packed = self._packed()
if packed.shape[0] == 0:
return self._slice_from(packed)
mask = (packed[:, _COL_T] >= float(t0)) & (packed[:, _COL_T] <= float(t1))
return self._slice_from(packed[mask])
# -------------------------------------------------------------- browsing
def extent(self) -> tuple[float, float]:
"""``(first_t, last_t)`` of the stored session; ``(0, 0)`` when empty."""
with self._lock:
if self._count == 0 or self._t0 is None or self._last_t is None:
return (0.0, 0.0)
first = self._blocks[0][0, _COL_T] if self._blocks else 0.0
return (float(first), float(self._last_t - self._t0))
def _block_span(self, block: np.ndarray, rows: int) -> tuple[float, float]:
return (float(block[0, _COL_T]), float(block[rows - 1, _COL_T]))
def visible(
self, t0: float, t1: float, max_points: int = 2000, *, unit: str = "raw"
) -> SessionSlice:
"""Decimated slice for drawing, keeping peaks on every plotted axis.
Only the blocks that overlap ``[t0, t1]`` are touched, so browsing a
long session does not copy the whole thing, and the decimation keeps the
extremes of all three axes in ``unit`` so no channel's spike is smoothed
away.
"""
t0 = float(t0)
t1 = float(t1)
if t1 < t0:
t0, t1 = t1, t0
with self._lock:
rows = self._packed_window(t0, t1)
truncated = self._dropped > 0
if max_points and rows.shape[0] > max_points:
rows = _decimate_preserving_peaks(rows, max_points, unit)
return replace(self._slice_from(rows), truncated=truncated)
def _packed_window(self, t0: float, t1: float) -> np.ndarray:
"""Concatenate only the rows inside the window. Caller holds the lock."""
if self._count == 0:
return np.empty((0, _COLUMNS), dtype=np.float64)
selected: list[np.ndarray] = []
remaining = self._count
for block in self._blocks:
rows = min(remaining, block.shape[0])
remaining -= rows
if rows <= 0:
break
span = self._block_span(block, rows)
if span[1] < t0 or span[0] > t1:
continue # blocks are time-ordered: skip whole blocks
times = block[:rows, _COL_T]
start = int(np.searchsorted(times, t0, side="left"))
stop = int(np.searchsorted(times, t1, side="right"))
if stop > start:
selected.append(block[start:stop])
if not selected:
return np.empty((0, _COLUMNS), dtype=np.float64)
return np.vstack(selected) if len(selected) > 1 else selected[0]
def statistics(self, t0: float, t1: float, unit: str = "raw") -> "SelectionStats":
"""Duration, peak, mean and std over a selection, per channel."""
t0 = float(t0)
t1 = float(t1)
if t1 < t0:
t0, t1 = t1, t0
with self._lock:
rows = self._packed_window(t0, t1)
if rows.shape[0] == 0:
return SelectionStats(duration_s=0.0, samples=0)
mask = rows[:, _COL_VALID] > 0.5
valid_rows = rows[mask]
times = rows[:, _COL_T]
duration = float(times[-1] - times[0]) if times.size else 0.0
if valid_rows.shape[0] == 0:
return SelectionStats(duration_s=duration, samples=0)
columns = (
(_COL_FN_N, _COL_FX_N, _COL_FY_N)
if str(unit).upper().startswith("N")
else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
)
peaks: list[float] = []
means: list[float] = []
stds: list[float] = []
for column in columns:
values = valid_rows[:, column]
values = values[np.isfinite(values)]
if values.size == 0:
peaks.append(NA)
means.append(NA)
stds.append(NA)
continue
peaks.append(float(values[np.argmax(np.abs(values))]))
means.append(float(values.mean()))
stds.append(float(values.std()))
return SelectionStats(
duration_s=duration,
samples=int(valid_rows.shape[0]),
peaks=tuple(peaks),
means=tuple(means),
stds=tuple(stds),
)
def _slice_from(self, packed: np.ndarray) -> SessionSlice:
if packed.shape[0] == 0:
empty = np.empty(0, dtype=np.float64)
return SessionSlice(
times=empty,
raw=np.empty((0, 3)),
newtons=np.empty((0, 3)),
valid=np.empty(0, dtype=bool),
frame_ids=np.empty(0, dtype=np.int64),
)
return SessionSlice(
times=packed[:, _COL_T].copy(),
raw=packed[:, [_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW]].copy(),
newtons=packed[:, [_COL_FN_N, _COL_FX_N, _COL_FY_N]].copy(),
valid=packed[:, _COL_VALID] > 0.5,
frame_ids=packed[:, _COL_FRAME_ID].astype(np.int64, copy=True),
truncated=self._dropped > 0,
)
def iter_valid(self, unit: str = "raw") -> Iterator[tuple[float, float, float, float]]:
"""Yield ``(t_rel_s, v0, v1, v2)`` for valid samples only.
Calibration fitting uses this so a failed readout can never be averaged
in as a zero.
"""
slice_ = self.data()
values = slice_.value_for_unit(unit)
for index in range(len(slice_)):
if not bool(slice_.valid[index]):
continue
row = values[index]
if not all(np.isfinite(v) for v in row):
continue
yield (
float(slice_.times[index]),
float(row[0]),
float(row[1]),
float(row[2]),
)
def summarise(values: Iterable[float]) -> tuple[float, float, int]:
"""Return ``(mean, std, n)`` over the finite values, NaN when empty."""
block = np.asarray([v for v in values if np.isfinite(v)], dtype=np.float64)
if block.size == 0:
return (NA, NA, 0)
return (float(block.mean()), float(block.std()), int(block.size))
@@ -0,0 +1,228 @@
"""One interface for reading curve data, whatever it is stored in.
The live charts read a bounded in-memory session store; the replay reads a
recording on disk. Both have to answer the same questions — what is the extent,
what is in this interval, what does the whole session look like as an envelope —
so the plotting code asks a *source* instead of knowing which one it has.
A source never hands over more than the caller's point budget, and it always
reports what it had to do to fit: ``decimated`` means the values are a view, not
every sample.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import numpy as np
from orisys_app_shared.force.session import NA, SessionSlice
@dataclass(frozen=True)
class SourceStats:
"""What the selection readout shows, for any source."""
duration_s: float = 0.0
samples: int = 0
peaks: tuple[float, float, float] = (NA, NA, NA)
means: tuple[float, float, float] = (NA, NA, NA)
stds: tuple[float, float, float] = (NA, NA, NA)
@dataclass
class SourceWindow:
"""Rows for one interval, in the shape the charts already consume."""
times: np.ndarray
values: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
unit: str = "raw"
decimated: bool = False
def __len__(self) -> int:
return int(self.times.size)
def plottable_axis(self, index: int):
from orisys_app_shared.record.sample import plottable_axis_mask
mask = plottable_axis_mask(self.values, self.valid)[:, index]
return self.times[mask], self.values[mask, index]
def statistics(self) -> SourceStats:
mask = np.isfinite(self.values) & self.valid[:, None]
peaks: list[float] = []
means: list[float] = []
stds: list[float] = []
for index in range(3):
column = self.values[:, index][mask[:, index]]
if column.size == 0:
peaks.append(NA)
means.append(NA)
stds.append(NA)
continue
peaks.append(float(column[np.argmax(np.abs(column))]))
means.append(float(np.nanmean(column)))
stds.append(float(np.nanstd(column)))
span = float(self.times[-1] - self.times[0]) if self.times.size > 1 else 0.0
return SourceStats(
duration_s=span,
samples=int(mask.any(axis=1).sum()),
peaks=tuple(peaks),
means=tuple(means),
stds=tuple(stds),
)
class CurveSource(Protocol):
"""What the charts need from wherever the data lives."""
kind: str
def extent(self) -> tuple[float, float]: ...
def window(self, t0: float, t1: float, *, max_points: int | None = None) -> SourceWindow: ...
def envelope(self, *, buckets: int = 1200) -> SourceWindow: ...
def duration_s(self) -> float: ...
class StoreCurveSource:
"""The live session store: already in memory, bounded by its own blocks."""
kind = "memory"
def __init__(self, store=None) -> None:
self.store = store
def set_store(self, store) -> None:
self.store = store
def extent(self) -> tuple[float, float]:
if self.store is None:
return (0.0, 0.0)
return self.store.extent()
def duration_s(self) -> float:
if self.store is None:
return 0.0
first, last = self.store.extent()
return max(0.0, last - first)
def _slice(self, t0: float, t1: float, max_points: int | None, unit: str) -> SessionSlice:
if self.store is None:
empty = np.empty(0)
return SessionSlice(
times=empty,
raw=np.empty((0, 3)),
newtons=np.empty((0, 3)),
valid=np.empty(0, dtype=bool),
frame_ids=np.empty(0, dtype=np.int64),
)
return self.store.visible(
t0, t1, max_points=max_points or 0, unit=unit
)
def window(
self,
t0: float,
t1: float,
*,
max_points: int | None = None,
unit: str = "raw",
) -> SourceWindow:
slice_ = self._slice(t0, t1, max_points, unit)
return SourceWindow(
times=slice_.times,
values=slice_.value_for_unit(unit),
valid=slice_.valid,
frame_ids=slice_.frame_ids,
unit=unit,
decimated=bool(max_points and slice_.times.size >= int(max_points)),
)
def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow:
"""The whole session, decimated — the store's own bounded view."""
if self.store is None:
return self.window(0.0, 0.0)
first, last = self.store.extent()
return self.window(first, last, max_points=int(buckets) * 2, unit=unit)
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
if self.store is None:
return SourceStats()
stats = self.store.statistics(t0, t1, unit=unit)
return SourceStats(
duration_s=stats.duration_s,
samples=stats.samples,
peaks=stats.peaks,
means=stats.means,
stds=stats.stds,
)
class RecordingCurveSource:
"""A recording on disk: interval reads plus a cached envelope."""
kind = "disk"
def __init__(self, reader) -> None:
self.reader = reader
def extent(self) -> tuple[float, float]:
return self.reader.index().extent()
def duration_s(self) -> float:
return self.reader.index().duration_s()
def window(
self,
t0: float,
t1: float,
*,
max_points: int | None = None,
unit: str = "raw",
) -> SourceWindow:
window = self.reader.window(t0, t1, unit=unit, max_points=max_points)
return SourceWindow(
times=window.times,
values=window.value_for_unit(unit),
valid=window.valid,
frame_ids=window.frame_ids,
unit=unit,
decimated=window.decimated,
)
def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow:
"""The whole-session envelope, from the index's cached overview."""
overview = self.reader.overview(unit=unit, buckets=int(buckets))
keep = ~np.isnan(overview.times)
times = overview.times[keep]
if times.size == 0:
return self.window(0.0, 0.0, unit=unit)
# The envelope is drawn as a band: the low and high of each bucket. It is
# reported as a plain window so callers need only one shape.
values = np.where(
np.isnan(overview.highs[keep]), overview.lows[keep], overview.highs[keep]
)
return SourceWindow(
times=times,
values=values,
valid=np.ones(times.size, dtype=bool),
frame_ids=np.full(times.size, -1, dtype=np.int64),
unit=unit,
decimated=True,
)
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
stats = self.reader.statistics(t0, t1, unit=unit)
return SourceStats(
duration_s=stats.duration_s,
samples=stats.samples,
peaks=stats.peaks,
means=stats.means,
stds=stats.stds,
)
+84 -26
View File
@@ -1,46 +1,104 @@
"""Force unit selection for display."""
"""Force unit selection for display.
The decision is made **per axis** from the standardised measurement
(:class:`~orisys_app_shared.record.sample.RecordSample`), not from a device-level
``force_calibrated`` flag. A device can legitimately have only its normal-force
axis calibrated: the old rule then either showed every axis in raw (losing the
one axis that really was in newtons) or, worse, showed raw counts in the newton
panels. An axis without a curve is now simply **unavailable** in newton mode and
reported as such.
"""
from __future__ import annotations
from dataclasses import dataclass
from orisys_app_shared.record.sample import AXES
#: Index of each axis inside the triplets, matching ``AXES``.
AXIS_INDEX: dict[str, int] = {axis: index for index, axis in enumerate(AXES)}
@dataclass(frozen=True)
class DisplayForces:
fn: float
fx: float
fy: float
"""What the live view should show for one sample."""
#: One value per axis, in ``AXES`` order. NaN means "not available".
values: tuple[float, float, float]
#: The unit the values actually are (``N`` only when they are newtons).
unit: str
#: Per axis: is this value a real measurement in ``unit``?
available: tuple[bool, bool, bool]
#: The requested unit was newton but no axis had a curve, so raw is shown.
using_raw_fallback: bool
#: What the operator asked for (``N``/``raw``), for the fallback message.
requested_unit: str
def value(self, axis: str) -> float:
return self.values[AXIS_INDEX[axis]]
def is_available(self, axis: str) -> bool:
return self.available[AXIS_INDEX[axis]]
def unavailable_axes(self) -> tuple[str, ...]:
return tuple(
axis for axis in AXES if not self.available[AXIS_INDEX[axis]]
)
def select_display_forces(
*,
units_pref: str,
force_calibrated: bool,
fn: float,
fx: float,
fy: float,
fn_raw: float,
fx_raw: float,
fy_raw: float,
) -> DisplayForces:
"""Return values for plots/labels given newton/raw preference."""
def _finite(value: float) -> bool:
try:
return value == value and value not in (float("inf"), float("-inf"))
except TypeError: # pragma: no cover - defensive
return False
def select_display_forces(*, units_pref: str, sample) -> DisplayForces:
"""Pick the values to display for one standardised measurement.
``sample`` is a
:class:`~orisys_app_shared.record.sample.RecordSample`, so the same conversion
that feeds the CSV and the session store also decides what the live charts
show: there is no second interpretation of what a value means.
"""
want_newton = str(units_pref or "newton").lower().startswith("n")
if want_newton and force_calibrated:
return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False)
if want_newton and not force_calibrated:
raw = tuple(sample.raw_for(axis) for axis in AXES)
newton = tuple(sample.newton_for(axis) for axis in AXES)
requested = "N" if want_newton else "raw"
if not want_newton:
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
values=raw,
unit="raw",
available=tuple(_finite(value) for value in raw),
using_raw_fallback=False,
requested_unit=requested,
)
values: list[float] = []
available: list[bool] = []
for index, _axis in enumerate(AXES):
if _finite(newton[index]):
values.append(newton[index])
available.append(True)
else:
# Not "zero" and not the raw count: there is no newton value at all.
values.append(float("nan"))
available.append(False)
if not any(available):
# Nothing is calibrated: showing raw is honest as long as it is labelled.
return DisplayForces(
values=raw,
unit="raw",
available=tuple(_finite(value) for value in raw),
using_raw_fallback=True,
requested_unit=requested,
)
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
unit="raw",
values=tuple(values),
unit="N",
available=tuple(available),
using_raw_fallback=False,
requested_unit=requested,
)
+15 -8
View File
@@ -10,25 +10,25 @@ def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _customer_root_from_shared() -> Path:
"""Repo layout: shared/src/orisys_app_shared -> apps/customer."""
def _studio_root_from_shared() -> Path:
"""Repo layout: shared/src/orisys_app_shared -> apps/studio."""
here = Path(__file__).resolve()
# .../orisys-app/shared/src/orisys_app_shared/paths.py
repo_root = here.parents[3]
return repo_root / "apps" / "customer"
return repo_root / "apps" / "studio"
def app_root() -> Path:
"""Return the customer app root (editable) or EXE directory (frozen)."""
"""Return the studio app root (editable) or EXE directory (frozen)."""
if is_frozen():
return Path(sys.executable).resolve().parent
# Prefer cwd override when launched from apps/customer
# Prefer cwd override when launched from apps/studio
cwd = Path.cwd().resolve()
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
return cwd
if (cwd / "apps" / "customer" / "config").is_dir():
return cwd / "apps" / "customer"
return _customer_root_from_shared()
if (cwd / "apps" / "studio" / "config").is_dir():
return cwd / "apps" / "studio"
return _studio_root_from_shared()
def config_dir() -> Path:
@@ -68,6 +68,13 @@ def calibration_export_dir() -> Path:
return path
def recordings_dir() -> Path:
"""Root for one directory per recorded experiment."""
path = outputs_dir() / "recordings"
path.mkdir(parents=True, exist_ok=True)
return path
def preference_path() -> Path:
return app_root() / "preference.json"
@@ -24,8 +24,22 @@ _DEFAULTS: dict[str, Any] = {
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
# Raw (uncalibrated) HHD units. Sized so the idle jitter is sub-pixel on
# both raw-mode products while never clipping: measured peaks are ~447k
# (mini, 365x365) and ~41k (finger, 280x300); idle sigma is ~80 (finger).
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"show_fps": False,
#: The top rail's settings area (source / camera / config / units) starts
#: collapsed: the rail is for state, not configuration.
"settings_expanded": False,
#: Top-level view: "custom" (a hand-arranged layout), "live", "replay" or
#: "calibration". Each view is a layout of the same window.
"view_preset": "custom",
"splitter_sizes": [340, 480, 400],
"window_default_size": [1440, 880],
}
@@ -0,0 +1,41 @@
"""Experiment recording (video + data + metadata) and replay."""
from orisys_app_shared.record.calibration_snapshot import (
list_calibration_snapshots,
store_calibration_snapshot,
)
from orisys_app_shared.record.experiment import (
EVENTS_HEADER,
FORCES_HEADER,
FRAMES_HEADER,
RECORD_FORMAT_VERSION,
ExperimentRecorder,
RecordConfig,
RecordReport,
RecordStats,
format_value,
)
from orisys_app_shared.record.playback import (
RecordingFrame,
RecordingInfo,
RecordingReader,
list_recordings,
)
__all__ = [
"EVENTS_HEADER",
"FORCES_HEADER",
"FRAMES_HEADER",
"RECORD_FORMAT_VERSION",
"ExperimentRecorder",
"RecordConfig",
"RecordReport",
"RecordStats",
"RecordingFrame",
"RecordingInfo",
"RecordingReader",
"format_value",
"list_calibration_snapshots",
"list_recordings",
"store_calibration_snapshot",
]
@@ -0,0 +1,60 @@
"""Store the coefficients a recording was made with, next to the recording.
The hash of a calibration tells you *which* coefficients were applied; it does not
let you check what they were, and the original export file can be overwritten or
deleted afterwards. So the recording keeps its own copy:
```
outputs/recordings/<stamp>_session<NNNN>/
calibration/
force_calibration_<hash12>.json
```
The copy is content-addressed by the digest, so applying the same calibration
twice in one session stores one file, and two different calibrations can never
collide.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
#: Directory inside a recording that holds the coefficient snapshots.
SNAPSHOT_DIRNAME = "calibration"
def snapshot_name(version: str, source: Path) -> str:
"""The snapshot's file name: content-addressed, with the original extension."""
digest = str(version or "").strip()
stem = f"force_calibration_{digest[:12]}" if digest else f"force_calibration_{source.stem}"
return f"{stem}{source.suffix or '.json'}"
def store_calibration_snapshot(directory: str | Path, source: str | Path, version: str) -> str:
"""Copy ``source`` into ``directory/calibration``. Returns the relative name.
Raises on I/O failure; the caller reports it and keeps recording.
"""
base = Path(directory) / SNAPSHOT_DIRNAME
base.mkdir(parents=True, exist_ok=True)
origin = Path(source)
target = base / snapshot_name(version, origin)
if not target.is_file() or target.stat().st_size != origin.stat().st_size:
# Same content-addressed name means the same coefficients; a size check
# is enough to notice a truncated previous copy.
shutil.copy2(origin, target)
return f"{SNAPSHOT_DIRNAME}/{target.name}"
def list_calibration_snapshots(directory: str | Path) -> list[str]:
"""Every snapshot already stored in a recording, sorted."""
base = Path(directory) / SNAPSHOT_DIRNAME
if not base.is_dir():
return []
return sorted(
f"{SNAPSHOT_DIRNAME}/{path.name}" for path in base.iterdir() if path.is_file()
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
"""A time index over a recorded ``forces.csv``, so history is read on demand.
The replay used to load every row of a recording into Python lists and then into
arrays. That is fine for a minute and hopeless for the multi-hour recordings the
store is designed for, and it made the charts the only place where "the whole
session" had to exist in memory at once.
This index keeps the *small* things in memory — one time per row, one validity
flag, one frame id, plus periodic byte offsets — and reads the *values* only for
the interval being drawn. It also builds a multi-level overview (per-bucket
min/max/count) while streaming the file once, which is what a whole-session strip
needs and what it cannot get by sampling a few thousand rows.
Nothing here touches Qt. Unordered recordings keep a stable time-to-byte-offset
mapping, shared by all interval reads and statistics.
"""
from __future__ import annotations
import csv
import logging
from array import array
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Iterator, Optional
import numpy as np
logger = logging.getLogger(__name__)
#: How many rows to read between two recorded byte offsets. Larger means a
#: cheaper interval read but a longer scan to reach the interval.
CHECKPOINT_ROWS = 4096
#: Default number of overview buckets for the whole-session strip.
DEFAULT_BUCKETS = 1200
_FORCE_COLUMNS = ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
def _parse(text: str) -> float:
text = (text or "").strip()
if not text:
# An empty cell means "not available"; it must never become 0.
return float("nan")
try:
return float(text)
except ValueError:
return float("nan")
@dataclass
class ForceWindow:
"""Rows inside one interval — the same shape a session slice has."""
times: np.ndarray
raw: np.ndarray
newtons: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
#: True when the rows were decimated to satisfy the caller's point budget.
decimated: bool = False
def __len__(self) -> int:
return int(self.times.size)
def value_for_unit(self, unit: str) -> np.ndarray:
if str(unit).upper().startswith("N"):
return self.newtons
return self.raw
def plottable(self, unit: str):
"""``(times, values, mask)`` with a per-axis mask — the shared rule."""
from orisys_app_shared.record.sample import plottable_axis_mask
values = self.value_for_unit(unit)
return self.times, values, plottable_axis_mask(values, self.valid)
def plottable_axis(self, unit: str, index: int):
from orisys_app_shared.record.sample import plottable_axis_mask
values = self.value_for_unit(unit)
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
def statistics(self, unit: str) -> "WindowStats":
values = self.value_for_unit(unit)
mask = np.isfinite(values) & self.valid[:, None]
peaks: list[float] = []
means: list[float] = []
stds: list[float] = []
for index in range(3):
column = values[:, index][mask[:, index]]
if column.size == 0:
peaks.append(float("nan"))
means.append(float("nan"))
stds.append(float("nan"))
continue
peaks.append(float(column[np.argmax(np.abs(column))]))
means.append(float(np.nanmean(column)))
stds.append(float(np.nanstd(column)))
span = 0.0
if self.times.size > 1:
span = float(self.times[-1] - self.times[0])
return WindowStats(
duration_s=span,
samples=int(mask.any(axis=1).sum()),
peaks=tuple(peaks),
means=tuple(means),
stds=tuple(stds),
)
@dataclass(frozen=True)
class WindowStats:
duration_s: float = 0.0
samples: int = 0
peaks: tuple[float, float, float] = (float("nan"),) * 3
means: tuple[float, float, float] = (float("nan"),) * 3
stds: tuple[float, float, float] = (float("nan"),) * 3
@dataclass
class Overview:
"""A whole-session envelope, cheap enough to keep in memory."""
times: np.ndarray
lows: np.ndarray # (buckets, 3)
highs: np.ndarray # (buckets, 3)
counts: np.ndarray # (buckets,)
unit: str = "raw"
def __len__(self) -> int:
return int(self.times.size)
@dataclass
class _Checkpoint:
row: int
time: float
offset: int
class RecordingIndex:
"""Index one recording directory's ``forces.csv`` (and its validity flags)."""
def __init__(self, directory: str | Path, *, checkpoint_rows: int = CHECKPOINT_ROWS):
self.directory = Path(directory)
self.checkpoint_rows = max(1, int(checkpoint_rows))
self.times = np.empty(0, dtype=np.float64)
self.valid = np.empty(0, dtype=bool)
self.frame_ids = np.empty(0, dtype=np.int64)
self._checkpoints: list[_Checkpoint] = []
self._rows = 0
self._byte_size = 0
self._built = False
self._offsets = None # Only unordered files need one byte offset per row.
self._columns = {}
self.ordered = True
self.dropped_rows = 0
# ------------------------------------------------------------------ building
@property
def path(self) -> Path:
return self.directory / "forces.csv"
@property
def rows(self) -> int:
return self._rows
@property
def byte_size(self) -> int:
"""Size of the data file the index describes."""
return self._byte_size
def build(self) -> "RecordingIndex":
"""One streaming pass: times, flags, frame ids and byte offsets."""
path = self.path
times: list[float] = []
valid: list[bool] = []
frame_ids: list[int] = []
checkpoints: list[_Checkpoint] = []
offsets = array("Q")
self.dropped_rows = 0
if path.is_file():
with path.open("rb") as handle:
header = handle.readline().decode("utf-8", "replace").strip()
columns = {name: index for index, name in enumerate(header.split(","))}
self._columns = columns
offset = handle.tell()
for row, line in enumerate(handle):
next_offset = handle.tell()
record = self._parse_row(line)
if record is None:
self.dropped_rows += 1
offset = next_offset
continue
frame_id, timestamp, _raw, _newtons, is_valid = record
logical_row = len(times)
if logical_row % self.checkpoint_rows == 0:
checkpoints.append(
_Checkpoint(row=logical_row, time=timestamp, offset=offset)
)
times.append(timestamp)
valid.append(is_valid)
frame_ids.append(frame_id)
offsets.append(offset)
offset = next_offset
self.times = np.asarray(times, dtype=np.float64)
self.valid = np.asarray(valid, dtype=bool)
self.frame_ids = np.asarray(frame_ids, dtype=np.int64)
self.ordered = bool(np.all(self.times[1:] >= self.times[:-1]))
self._offsets = None
if not self.ordered:
order = np.argsort(self.times, kind="stable")
self.times = self.times[order]
self.valid = self.valid[order]
self.frame_ids = self.frame_ids[order]
self._offsets = np.asarray(offsets, dtype=np.uint64)[order]
self._checkpoints = checkpoints
self._rows = int(self.times.size)
try:
self._byte_size = path.stat().st_size
except OSError:
self._byte_size = 0
self._built = True
return self
def _parse_row(self, line: bytes):
"""Parse atomically; malformed rows never shift one column alone."""
try:
fields = next(csv.reader([line.decode("utf-8", "replace")]))
def cell(name):
return fields[self._columns[name]]
frame_id = int(cell("frame_id"))
timestamp = float(cell("t_rel_s"))
if not np.isfinite(timestamp) or not -(2**63) <= frame_id < 2**63:
return None
values = tuple(_parse(cell(name)) for name in _FORCE_COLUMNS)
return frame_id, timestamp, values[:3], values[3:], cell("valid").strip() == "1"
except (KeyError, IndexError, ValueError, StopIteration, csv.Error):
return None
def ensure_built(self) -> "RecordingIndex":
if not self._built:
self.build()
return self
# ------------------------------------------------------------------- queries
def extent(self) -> tuple[float, float]:
self.ensure_built()
if self._rows == 0:
return (0.0, 0.0)
return (float(self.times[0]), float(self.times[-1]))
def duration_s(self) -> float:
first, last = self.extent()
return max(0.0, last - first)
def row_range(self, t0: float, t1: float) -> tuple[int, int]:
"""Half-open row range covering ``[t0, t1]``."""
self.ensure_built()
if self._rows == 0:
return (0, 0)
if float(t1) < float(t0):
t0, t1 = t1, t0
start = int(self.times.searchsorted(float(t0), side="left"))
stop = int(self.times.searchsorted(float(t1), side="right"))
return (start, max(start, stop))
def _checkpoint_before(self, row: int) -> _Checkpoint:
chosen = _Checkpoint(row=0, time=0.0, offset=0)
for checkpoint in self._checkpoints:
if checkpoint.row <= row:
chosen = checkpoint
else:
break
return chosen
def read_rows(
self,
start: int,
stop: int,
*,
max_points: int | None = None,
unit: str = "raw",
) -> ForceWindow:
"""Read ``[start, stop)`` by seeking to the nearest checkpoint.
With ``max_points`` the rows are decimated **while streaming**: each
bucket contributes the extreme row of every axis in ``unit``, so a long
interval never has to exist in memory in full. Extrema are taken independently on all three axes in the requested unit,
so a shear spike survives a flat normal channel. A bucket keeps at most
nine rows; budgets smaller than nine may therefore return nine points.
"""
self.ensure_built()
start = max(0, min(int(start), self._rows))
stop = max(start, min(int(stop), self._rows))
if stop <= start:
return _empty_window()
path = self.path
if not path.is_file():
return _empty_window()
newton_unit = str(unit).upper().startswith("N")
budget = int(max_points) if max_points else 0
total = stop - start
# At most six extrema, two endpoints and a missing-data marker per
# bucket. No bucket holds a list proportional to the recording length.
bucket = max(1, (total + max(1, budget // 9) - 1) // max(1, budget // 9)) if budget and total > budget else 1
picked: list[tuple] = []
extrema = {}
first = last = missing = None
count = 0
def flush():
nonlocal first, last, missing, count
chosen = {item[0]: item for item in extrema.values()}
for item in (first, last, missing):
if item is not None:
chosen[item[0]] = item
picked.extend(item[1:] for item in sorted(chosen.values(), key=lambda item: item[0]))
extrema.clear()
first = last = missing = None
count = 0
for item in self.iter_rows(start, stop):
# item: logical row, frame id, time, raw, newtons, validity.
if bucket == 1:
picked.append(item[1:])
continue
if first is None:
first = item
last = item
values = item[4] if newton_unit else item[3]
for axis, value in enumerate(values):
if not item[5] or not np.isfinite(value):
if missing is None:
missing = item
continue
for side in ('min', 'max'):
key = (axis, side)
previous = extrema.get(key)
old = previous[4 if newton_unit else 3][axis] if previous is not None else None
if old is None or (value < old if side == 'min' else value > old):
extrema[key] = item
count += 1
if count >= bucket:
flush()
flush()
if not picked:
return _empty_window()
# A row can be the extreme of several axes; keep one copy, in time order.
rows = picked # Already in stable time order; duplicate frame IDs are not deduplicated.
return ForceWindow(
times=np.asarray([row[1] for row in rows], dtype=np.float64),
raw=np.asarray([row[2] for row in rows], dtype=np.float64).reshape(-1, 3),
newtons=np.asarray([row[3] for row in rows], dtype=np.float64).reshape(-1, 3),
valid=np.asarray([row[4] for row in rows], dtype=bool),
frame_ids=np.asarray([row[0] for row in rows], dtype=np.int64),
decimated=bucket > 1,
)
def iter_rows(self, start: int = 0, stop: Optional[int] = None) -> Iterator[tuple]:
"""Yield logical rows in stable time order, seeking only the selection."""
self.ensure_built()
start = max(0, int(start))
stop = self._rows if stop is None else min(int(stop), self._rows)
if start >= stop or not self.path.is_file():
return
with self.path.open("rb") as handle:
if self._offsets is not None:
for row in range(start, stop):
handle.seek(int(self._offsets[row]))
record = self._parse_row(handle.readline())
if record is not None:
yield (row, *record)
return
checkpoint = self._checkpoint_before(start)
handle.seek(checkpoint.offset)
row = checkpoint.row
for line in handle:
record = self._parse_row(line)
if record is None:
continue
if row >= start:
yield (row, *record)
row += 1
if row >= stop:
return
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> WindowStats:
"""Exact selected-row statistics with constant working memory."""
start, stop = self.row_range(t0, t1)
counts = np.zeros(3, dtype=np.int64)
means = np.zeros(3)
m2 = np.zeros(3)
peaks = np.full(3, np.nan)
samples = 0
first = last = None
newton = str(unit).upper().startswith("N")
for _, _, timestamp, raw, newtons, valid in self.iter_rows(start, stop):
if first is None:
first = timestamp
last = timestamp
if not valid:
continue
values = np.asarray(newtons if newton else raw)
mask = np.isfinite(values)
samples += int(mask.any())
for axis in np.flatnonzero(mask):
value = values[axis]
counts[axis] += 1
delta = value - means[axis]
means[axis] += delta / counts[axis]
m2[axis] += delta * (value - means[axis])
if not np.isfinite(peaks[axis]) or abs(value) > abs(peaks[axis]):
peaks[axis] = value
available = counts > 0
stds = np.full(3, np.nan)
stds[available] = np.sqrt(np.maximum(0, m2[available] / counts[available]))
means[~available] = np.nan
return WindowStats(
duration_s=0.0 if first is None else last - first,
samples=samples, peaks=tuple(peaks), means=tuple(means), stds=tuple(stds),
)
# ------------------------------------------------------------------ overview
def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview:
"""A per-bucket envelope of the whole recording, in the active unit.
Built by streaming the file once and folding each row into its bucket, so
a peak in a bucket the strip would otherwise skip is still visible.
"""
self.ensure_built()
buckets = max(1, int(buckets))
newton = str(unit).upper().startswith("N")
if self._rows == 0:
return Overview(
times=np.empty(0),
lows=np.empty((0, 3)),
highs=np.empty((0, 3)),
counts=np.empty(0, dtype=np.int64),
unit="N" if newton else "raw",
)
first, last = self.extent()
span = max(1e-9, last - first)
lows = np.full((buckets, 3), np.nan)
highs = np.full((buckets, 3), np.nan)
counts = np.zeros(buckets, dtype=np.int64)
starts = np.full(buckets, np.nan)
for _row, _frame_id, t_rel, raw, newtons, is_valid in self.iter_rows():
if not is_valid:
continue
bucket = int((t_rel - first) / span * (buckets - 1) + 0.5)
bucket = max(0, min(buckets - 1, bucket))
values = newtons if newton else raw
for axis in range(3):
value = values[axis]
if value != value: # NaN
continue
current_low = lows[bucket, axis]
current_high = highs[bucket, axis]
lows[bucket, axis] = value if current_low != current_low else min(current_low, value)
highs[bucket, axis] = value if current_high != current_high else max(current_high, value)
if starts[bucket] != starts[bucket]:
starts[bucket] = t_rel
counts[bucket] += 1
return Overview(
times=starts,
lows=lows,
highs=highs,
counts=counts,
unit="N" if newton else "raw",
)
def _time_of(text: str) -> float:
fields = next(csv.reader([text])) if text else []
if len(fields) > 2:
try:
return float(fields[2])
except (TypeError, ValueError):
return float("nan")
return float("nan")
def _empty_window() -> ForceWindow:
return ForceWindow(
times=np.empty(0, dtype=np.float64),
raw=np.empty((0, 3)),
newtons=np.empty((0, 3)),
valid=np.empty(0, dtype=bool),
frame_ids=np.empty(0, dtype=np.int64),
)
@@ -0,0 +1,614 @@
"""Read a recorded experiment back: forces, the frame index and the video.
Replay policy for depth (decided with the team): **depth is not stored**. It is
a function of the frame and the sensor configuration, so it is recomputed from
``raw_*.mp4`` with the config recorded in ``session.json``. That keeps a
recording small at the cost of requiring the SDK to reproduce the same result,
which is why :meth:`RecordingReader.replay_warnings` checks that the config hash
and SDK version still match what was recorded.
Nothing here touches Qt, so the reader is testable without a display.
"""
from __future__ import annotations
import csv
import json
import logging
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterator, Optional
import numpy as np
from orisys_app_shared.record.index import (
CHECKPOINT_ROWS,
DEFAULT_BUCKETS,
ForceWindow,
Overview,
RecordingIndex,
)
from orisys_app_shared.record.sample import plottable_axis_mask
logger = logging.getLogger(__name__)
#: Column names as written by the recorder (index == position in forces.csv).
FORCE_COLUMNS = (
"session_id",
"frame_id",
"t_rel_s",
"FN_raw",
"FX_raw",
"FY_raw",
"FN_N",
"FX_N",
"FY_N",
"valid",
"calibration_id",
)
@dataclass(frozen=True)
class RecordingFrame:
"""One row of ``frames.csv`` — the mapping from a frame to the video."""
frame_id: int
t_rel_s: float
acquired_at: str
segment: int
segment_frame: int
in_video: bool
#: True when the frame's data row was lost and only this index row exists.
dropped: bool = False
@dataclass
class RecordingInfo:
directory: Path
session: dict
frames: list[RecordingFrame]
times: np.ndarray
raw: np.ndarray
newtons: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
dropped_rows: int = 0
frames_dropped: int = 0
#: The index rows were in time order as written (False = a legacy record).
frames_out_of_order: bool = True
#: The force rows were in time order as written.
forces_out_of_order: bool = True
#: True when the arrays above are a decimated view of a long recording.
decimated: bool = False
#: Total rows in ``forces.csv`` (the decimated view has fewer).
total_rows: int = 0
#: The recording's real duration, from the frame index, never from the view.
total_duration_s: float = 0.0
@property
def duration_s(self) -> float:
if self.total_duration_s:
return float(self.total_duration_s)
if self.times.size == 0:
return 0.0
return float(self.times[-1])
def value_for_unit(self, unit: str) -> np.ndarray:
if str(unit).upper().startswith("N"):
return self.newtons
return self.raw
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""``(times, values, mask)`` — the same rule the live charts use.
The replay used to filter on finiteness alone, so a stale value left by a
failed compute step could reappear here after another view had already
excluded it. ``mask`` is per axis: a single-axis calibration is normal and
must not hide the axis that *is* calibrated.
"""
values = self.value_for_unit(unit)
return self.times, values, plottable_axis_mask(values, self.valid)
def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]:
"""``(times, values)`` for one channel, with the other axes ignored."""
values = self.value_for_unit(unit)
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
class RecordingReader:
"""Open one experiment directory and serve replay queries from it.
``max_rows`` bounds what :meth:`info` keeps in memory: a long recording is
loaded as a decimated view (peaks preserved) and marked ``decimated``, while
:meth:`window` and :meth:`overview` read the interval being drawn from disk
through :class:`~orisys_app_shared.record.index.RecordingIndex`. That is what
lets the replay show an hour-long recording without holding all of it.
"""
#: Above this many rows ``info()`` decimates instead of loading everything.
DEFAULT_MAX_ROWS = 200_000
def __init__(
self,
directory: str | Path,
*,
max_rows: int | None = None,
checkpoint_rows: int = CHECKPOINT_ROWS,
) -> None:
self.directory = Path(directory)
if not self.directory.is_dir():
raise FileNotFoundError(f"Not a recording directory: {self.directory}")
self._info: Optional[RecordingInfo] = None
self._events: Optional[list[dict]] = None
self.max_rows = int(max_rows or self.DEFAULT_MAX_ROWS)
self._index = RecordingIndex(self.directory, checkpoint_rows=checkpoint_rows)
self._overviews: dict[tuple[str, int], Overview] = {}
#: One open decoder per segment, kept between calls. Opening the file and
#: seeking costs 10 ms for a small clip and hundreds for 1080p H.264, and
#: playback asks for a frame 30 times a second: reopening per frame is
#: what made the video stand still.
self._capture = None
self._capture_segment: int | None = None
self._capture_index = -1
self._capture_lock = threading.Lock()
# ------------------------------------------------------------------ loading
def info(self) -> RecordingInfo:
if self._info is None:
self._info = self._load()
return self._info
def index(self) -> RecordingIndex:
"""The time index, built once and shared with the windows."""
return self._index.ensure_built()
def session(self) -> dict:
path = self.directory / "session.json"
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 - a broken session.json must not crash replay
logger.exception("Could not read %s", path)
return {}
def _load(self) -> RecordingInfo:
frames, frames_ordered = self._load_frames()
index = self.index()
total_rows = index.rows
decimated = total_rows > self.max_rows > 0
if decimated:
window = self.window_all(
max_points=self.max_rows
)
times = window.times
raw = window.raw
newtons = window.newtons
valid = window.valid
frame_ids = window.frame_ids
forces_ordered = index.ordered
dropped = index.dropped_rows
else:
(times, raw, newtons, valid, frame_ids, dropped, forces_ordered) = (
self._load_forces()
)
total_duration = 0.0
if frames:
total_duration = float(frames[-1].t_rel_s)
elif times.size:
total_duration = float(times[-1])
return RecordingInfo(
directory=self.directory,
session=self.session(),
frames=frames,
times=times,
raw=raw,
newtons=newtons,
valid=valid,
frame_ids=frame_ids,
dropped_rows=dropped,
frames_dropped=sum(1 for frame in frames if frame.dropped),
frames_out_of_order=frames_ordered,
forces_out_of_order=forces_ordered,
decimated=decimated,
total_rows=total_rows,
total_duration_s=total_duration,
)
def _load_frames(self) -> tuple[list[RecordingFrame], bool]:
"""Load the frame index in time order.
Recordings made before the ordered-write fix (see
``ExperimentRecorder``) can contain a frame index whose rows are not in
time order, because the row backlog used to be drained before the queue.
``frame_at`` does a binary search over these times, so an unsorted index
would silently answer with the wrong frame. The rows are sorted here and
the fact is reported as a replay warning.
"""
path = self.directory / "frames.csv"
if not path.is_file():
return [], False
rows: list[RecordingFrame] = []
with path.open(encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle):
try:
rows.append(
RecordingFrame(
frame_id=int(row["frame_id"]),
t_rel_s=float(row["t_rel_s"]),
acquired_at=row.get("acquired_at", ""),
segment=int(row.get("segment") or 0),
segment_frame=int(row.get("segment_frame") or 0),
in_video=row.get("in_video") == "1",
dropped=(row.get("dropped") or "0").strip() == "1",
)
)
except (KeyError, TypeError, ValueError):
continue
ordered = _is_time_ordered([frame.t_rel_s for frame in rows])
if not ordered:
rows.sort(key=lambda frame: frame.t_rel_s)
return rows, ordered
def _load_forces(self):
index = self.index()
window = index.read_rows(0, index.rows)
return (window.times, window.raw, window.newtons, window.valid,
window.frame_ids, index.dropped_rows, index.ordered)
# ------------------------------------------------------------------ queries
def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]:
"""The recorded frame at or just before ``t_rel_s``.
The index is sorted at load time, so the binary search is valid; the
fallback walk keeps a hand-edited or truncated file from answering with
the wrong frame.
"""
frames = self.info().frames
if not frames:
return None
times = np.asarray([frame.t_rel_s for frame in frames], dtype=np.float64)
if not _is_time_ordered(times.tolist()):
candidates = [frame for frame in frames if frame.t_rel_s <= float(t_rel_s)]
return max(candidates, key=lambda frame: frame.t_rel_s) if candidates else frames[0]
index = int(np.searchsorted(times, float(t_rel_s), side="right")) - 1
index = max(0, min(index, len(frames) - 1))
return frames[index]
def segment_path(self, segment: int) -> Optional[Path]:
if segment <= 0:
return None
path = self.directory / f"raw_{segment:04d}.mp4"
return path if path.is_file() else None
def segments(self) -> list[Path]:
return sorted(self.directory.glob("raw_*.mp4"))
# ------------------------------------------------------------- replay plan
def events(self) -> list[dict]:
"""``events.csv`` rows, cached. Operator actions, not measurements."""
if self._events is None:
path = self.directory / "events.csv"
rows: list[dict] = []
if path.is_file():
with path.open(encoding="utf-8", newline="") as handle:
rows = [dict(row) for row in csv.DictReader(handle)]
self._events = rows
return self._events
def event_frames(self, kind: str) -> list[int]:
"""Frame ids of every event of ``kind`` that names a position."""
found: list[int] = []
for row in self.events():
if str(row.get("kind") or "") != kind:
continue
try:
frame_id = int(row.get("frame_id") or -1)
except (TypeError, ValueError):
continue
if frame_id >= 0:
found.append(frame_id)
return found
def last_reset_frame(self, at_or_before: int) -> Optional[int]:
"""The most recent reset at or before a frame — a tracker state origin.
``Sensor.reset`` clears the optical-flow tracker and the force filters, so
the state at a later frame is fully determined by the frames since that
reset and does not need any earlier history. Both spellings are accepted:
the app has logged ``zeroing`` since before the replay existed.
"""
candidates = [
frame_id
for kind in ("zeroing", "reset")
for frame_id in self.event_frames(kind)
if frame_id <= int(at_or_before)
]
return max(candidates) if candidates else None
def frame_of(self, frame_id: int) -> Optional[RecordingFrame]:
for frame in self.info().frames:
if frame.frame_id == int(frame_id):
return frame
return None
def segment_start_frame(self, segment: int) -> Optional[RecordingFrame]:
"""The first indexed frame that lives in ``segment``."""
rows = [frame for frame in self.info().frames if frame.segment == int(segment)]
if not rows:
return None
return min(rows, key=lambda frame: frame.segment_frame)
def preroll_frames(self) -> int:
return int(self.info().session.get("preroll_frames") or 0)
def preroll_segment(self) -> Optional[Path]:
"""The pre-roll video recorded just before this recording started."""
name = str(self.info().session.get("preroll_segment") or "")
if not name:
return None
path = self.directory / name
return path if path.is_file() else None
def video_frame_at(self, t_rel_s: float):
"""Decode the video frame for a replay position.
Returns ``(frame_bgr, RecordingFrame)`` or ``(None, frame)`` when the
frame was not written to video (the queue dropped it).
The decoder stays open and is *stepped* when the requested frame follows
the previous one, which is what playback does: a seek costs a keyframe
decode, a step costs one frame. Seeking is kept for jumps (dragging the
cursor).
"""
import cv2
frame = self.frame_at(t_rel_s)
if frame is None or not frame.in_video:
return None, frame
target = max(0, int(frame.segment_frame) - 1)
with self._capture_lock:
capture = self._capture_for(frame.segment)
if capture is None:
return None, frame
try:
if self._capture_index >= 0 and target == self._capture_index + 1:
ok, image = capture.read()
else:
capture.set(cv2.CAP_PROP_POS_FRAMES, target)
ok, image = capture.read()
except Exception: # noqa: BLE001 - a broken decode is not a crash
logger.exception("Could not decode frame %s", target)
self._release_capture()
return None, frame
# A failed read leaves the decoder's position unknown: start over next
# time rather than stepping from a frame that was never delivered.
self._capture_index = target if ok else -1
return (image if ok else None), frame
def _capture_for(self, segment: int):
"""The open decoder for ``segment``, opening (or switching) as needed."""
import cv2
if self._capture is not None and self._capture_segment == int(segment):
return self._capture
self._release_capture()
path = self.segment_path(int(segment))
if path is None:
return None
capture = cv2.VideoCapture(str(path))
if not capture.isOpened():
capture.release()
return None
self._capture = capture
self._capture_segment = int(segment)
self._capture_index = -1
return capture
def _release_capture(self) -> None:
if self._capture is not None:
try:
self._capture.release()
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("Could not release the replay decoder", exc_info=True)
self._capture = None
self._capture_segment = None
self._capture_index = -1
def close(self) -> None:
"""Release the decoder. The reader is reusable afterwards."""
with self._capture_lock:
self._release_capture()
def forces_window(self, t0: float, t1: float) -> tuple[np.ndarray, np.ndarray]:
"""``(times, values)`` inside ``[t0, t1]`` for the raw triplet."""
info = self.info()
if info.times.size == 0:
return info.times, info.raw
mask = (info.times >= float(t0)) & (info.times <= float(t1))
return info.times[mask], info.raw[mask]
# ------------------------------------------------------ on-demand history
def window(
self,
t0: float,
t1: float,
*,
unit: str = "raw",
max_points: int | None = None,
) -> ForceWindow:
"""The recorded rows inside ``[t0, t1]``, read from disk.
This is the interval read: it seeks to the nearest checkpoint in the
index and scans forward, so asking for ten seconds of an hour-long
recording costs ten seconds of parsing, not an hour. ``max_points``
decimates *while* reading, so the interval never has to exist in memory
in full.
"""
start, stop = self.index().row_range(t0, t1)
return self.index().read_rows(
start, stop, max_points=max_points, unit=unit
)
def window_all(self, *, unit: str = "raw", max_points: int | None = None) -> ForceWindow:
"""The whole recording as one window, decimated while streaming."""
return self.window(
*self.index().extent(), unit=unit, max_points=max_points
)
def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview:
"""A whole-session envelope, cached per (unit, bucket count)."""
key = (str(unit), int(buckets))
if key not in self._overviews:
self._overviews[key] = self.index().overview(
unit=str(unit), buckets=int(buckets)
)
return self._overviews[key]
def statistics(self, t0: float, t1: float, *, unit: str = "raw"):
"""Statistics of an interval, computed from the interval's own rows."""
return self.index().statistics(t0, t1, unit=unit)
# ------------------------------------------------------------------ policy
def depth_policy(self) -> dict:
return dict(self.info().session.get("depth") or {})
def depth_is_stored(self) -> bool:
return bool(self.depth_policy().get("stored", False))
def recorded_config_ref(self) -> str:
device = self.info().session.get("device") or {}
return str(device.get("config_ref") or "")
@staticmethod
def config_sha256(config_ref: str) -> str:
"""Hash of the config file currently on disk (empty when unreadable)."""
if not config_ref:
return ""
try:
import hashlib
path = Path(str(config_ref))
if path.is_file():
return hashlib.sha256(path.read_bytes()).hexdigest()
except Exception: # noqa: BLE001 - a missing config is reported, not raised
return ""
return ""
def replay_warnings(self, *, current_config_sha: str = "", sdk_version: str = "") -> list[str]:
"""What could stop this recording from replaying faithfully.
A changed config or SDK means the recorded frames are read by a different
pipeline than the one that produced them; that must be stated, not
discovered later.
"""
session = self.info().session
warnings: list[str] = []
recorded_sha = str((session.get("device") or {}).get("config_sha256") or "")
if not current_config_sha:
# Compare against the config file as it is now, which is exactly what
# a recompute over the video would use.
current_config_sha = self.config_sha256(self.recorded_config_ref())
if recorded_sha and current_config_sha and recorded_sha != current_config_sha:
warnings.append("config_changed")
versions = session.get("versions") or {}
recorded_sdk = str(versions.get("sdk") or "")
if recorded_sdk and sdk_version and recorded_sdk != sdk_version:
warnings.append("sdk_changed")
if recorded_sha and not current_config_sha:
warnings.append("config_missing")
if self.info().dropped_rows:
warnings.append("rows_skipped")
if self.info().frames_dropped:
warnings.append("frames_dropped")
if not self.info().frames_out_of_order or not self.info().forces_out_of_order:
warnings.append("out_of_order")
return warnings
def iter_forces(self, batch: int = 4096) -> Iterator[tuple[np.ndarray, np.ndarray]]:
"""Stream the force rows, so a long recording is not read twice."""
path = self.directory / "forces.csv"
if not path.is_file():
return
times: list[float] = []
values: list[tuple[float, float, float, float, float, float]] = []
with path.open(encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle):
try:
times.append(float(row["t_rel_s"]))
except (KeyError, TypeError, ValueError):
continue
values.append(
tuple(
float(row[key]) if (row.get(key) or "").strip() else float("nan")
for key in ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
)
)
if len(times) >= batch:
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
times, values = [], []
if times:
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
def _is_time_ordered(values) -> bool:
"""True when ``values`` never decreases (an empty or single value is ordered)."""
previous = None
for value in values:
if previous is not None and float(value) < float(previous):
return False
previous = value
return True
def decimate_window(window: ForceWindow, max_points: int, *, unit: str = "raw") -> ForceWindow:
"""Reduce a window to about ``max_points``, keeping every axis' extremes.
The same rule the live store uses: each bucket contributes the extreme row of
**every plotted axis** in the active unit, so a shear spike survives a flat
normal channel. The result is marked ``decimated``.
"""
total = len(window)
max_points = max(4, int(max_points))
if total <= max_points:
return window
values = window.value_for_unit(unit)
def index_of(row: int) -> int:
return int(window.frame_ids[row]) if total else row
bucket = max(1, total // max(1, max_points // 6))
picked: set[int] = set()
for start in range(0, total, bucket):
stop = min(total, start + bucket)
for axis in range(3):
column = values[start:stop, axis]
finite = np.isfinite(column)
if not finite.any():
continue
ordered = np.where(finite, column, -np.inf)
picked.add(start + int(np.argmin(ordered)))
picked.add(start + int(np.argmax(ordered)))
if not picked:
return window
rows = sorted(picked)
return ForceWindow(
times=window.times[rows],
raw=window.raw[rows],
newtons=window.newtons[rows],
valid=window.valid[rows],
frame_ids=window.frame_ids[rows],
decimated=True,
)
def list_recordings(root: str | Path) -> list[Path]:
base = Path(root)
if not base.is_dir():
return []
candidates = [
path
for path in base.iterdir()
if path.is_dir() and (path / "forces.csv").is_file()
]
return sorted(candidates, key=lambda path: path.name, reverse=True)
@@ -0,0 +1,238 @@
"""The one conversion from an acquired packet to a recorded sample.
Everything that records or displays force data goes through
:func:`to_record_sample`. There used to be two independent conversions — one in
``ExperimentRecorder.on_packet`` (for the CSV) and one in
``StudioMainWindow._on_packet_recorded`` (for the in-memory session store) — and
they disagreed with each other and with the truth:
* an uncalibrated sensor reports its raw counts in the *ordinary* force fields,
so those numbers were written into the ``FN_N``/``FX_N``/``FY_N`` columns;
* when the compute chain fails, the SDK's ``info.FNORMAL`` still returns the
value it last computed, so a failed frame recorded a **stale finite number**
that was indistinguishable from a fresh measurement.
The rules are therefore:
1. A sample whose compute chain failed is recorded with **all six force values
unavailable**. The value may belong to an earlier frame; nothing can prove
otherwise, so it is not recorded as a measurement.
2. A newton value is recorded **only for an axis that actually has a calibration
curve loaded**. An uncalibrated axis leaves its column empty.
3. A raw value is recorded only when the sensor exposes that raw channel.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterable
AXES: tuple[str, ...] = ("fn", "fx", "fy")
#: Reason recorded when a stale force value had to be discarded.
STALE_REASON = "stale forces: the compute chain failed for this frame"
def _finite(value: object) -> float:
"""Coerce to a float, mapping anything unusable to NaN."""
try:
number = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return float("nan")
return number if math.isfinite(number) else float("nan")
@dataclass(frozen=True)
class RecordSample:
"""One acquired step, as it is recorded."""
frame_id: int
#: Monotonic, session-relative acquisition time. This is the ordering key
#: for the session store, the CSV and the frame index; a wall clock that
#: steps backwards must not be able to reorder history.
mono_t: float
#: Wall clock at hand-off, kept for metadata only.
wall_t: float
valid: bool
invalid_reason: str
fn_raw: float = float("nan")
fx_raw: float = float("nan")
fy_raw: float = float("nan")
fn_n: float = float("nan")
fx_n: float = float("nan")
fy_n: float = float("nan")
calibrated_axes: tuple[str, ...] = ()
calibration_version: str = ""
# ------------------------------------------------------------- accessors
def raw_triplet(self) -> tuple[float, float, float]:
return (self.fn_raw, self.fx_raw, self.fy_raw)
def newton_triplet(self) -> tuple[float, float, float]:
return (self.fn_n, self.fx_n, self.fy_n)
def raw_for(self, axis: str) -> float:
return {"fn": self.fn_raw, "fx": self.fx_raw, "fy": self.fy_raw}.get(
axis, float("nan")
)
def newton_for(self, axis: str) -> float:
return {"fn": self.fn_n, "fx": self.fx_n, "fy": self.fy_n}.get(
axis, float("nan")
)
def has_newton(self, axis: str) -> bool:
return math.isfinite(self.newton_for(axis))
def has_raw(self, axis: str) -> bool:
return math.isfinite(self.raw_for(axis))
def value_for_unit(self, axis: str, unit: str) -> float:
if str(unit).upper().startswith("N"):
return self.newton_for(axis)
return self.raw_for(axis)
def as_row(self) -> tuple:
"""Positional form used by the CSV writer and the session store."""
return (
self.frame_id,
self.mono_t,
self.fn_raw,
self.fx_raw,
self.fy_raw,
self.fn_n,
self.fx_n,
self.fy_n,
1 if self.valid else 0,
)
def calibrated_axes_of(packet) -> tuple[str, ...]:
"""Which axes have a calibration curve, per axis rather than per sensor."""
declared = tuple(str(axis) for axis in (getattr(packet, "calibrated_axes", ()) or ()))
if declared:
return tuple(axis for axis in AXES if axis in declared)
# Backwards compatible fallback for packets that only carry the sensor-level
# flag: calibrated means all three, uncalibrated means none.
if bool(getattr(packet, "force_calibrated", False)):
return AXES
return ()
def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample:
"""Convert one packet into the single canonical recorded form."""
frame_id = int(getattr(packet, "frame_id", 0))
# ``mono_t`` is the acquisition clock and is authoritative when present; a
# packet that carries only a wall clock (a test double, or an older
# producer) still has to produce a *moving* time, never a constant one.
mono_t = _finite(getattr(packet, "mono_t", None))
wall_t = _finite(getattr(packet, "wall_t", None))
if not math.isfinite(mono_t):
mono_t = wall_t
if not math.isfinite(mono_t):
mono_t = _finite(getattr(packet, "timestamp", 0.0))
if not math.isfinite(wall_t):
wall_t = _finite(getattr(packet, "timestamp", 0.0))
if not math.isfinite(wall_t):
wall_t = mono_t
valid = bool(getattr(packet, "valid", True))
reason = str(getattr(packet, "invalid_reason", "") or "")
stale = bool(getattr(packet, "stale_forces", False))
# A packet that *carries* a version owns it, including the empty one: the
# acquisition thread stamps every frame, and an empty stamp is the device
# saying "no calibration loaded". Falling back to the session default there
# would resurrect coefficients that are no longer in effect. A packet from an
# older producer that has no version field at all still gets the default.
packet_version = getattr(packet, "calibration_version", None)
version = (
str(calibration_version or "")
if packet_version is None
else str(packet_version)
)
axes = calibrated_axes_of(packet)
if not valid or stale:
if stale and not reason:
reason = STALE_REASON
# Rule 1: a frame with no trustworthy readout records nothing numeric.
return RecordSample(
frame_id=frame_id,
mono_t=mono_t,
wall_t=wall_t,
valid=False,
invalid_reason=reason,
calibrated_axes=axes,
calibration_version=version,
)
raw_available = bool(getattr(packet, "raw_available", True))
raw = (
_finite(getattr(packet, "normal_force_raw", None)),
_finite(getattr(packet, "shear_x_raw", None)),
_finite(getattr(packet, "shear_y_raw", None)),
)
if not raw_available:
raw = (float("nan"), float("nan"), float("nan"))
newton = (
_finite(getattr(packet, "normal_force", None)),
_finite(getattr(packet, "shear_x", None)),
_finite(getattr(packet, "shear_y", None)),
)
# Rule 2: an axis without a calibration curve has no newton value at all.
newton = tuple(
value if axis in axes else float("nan")
for axis, value in zip(AXES, newton)
)
return RecordSample(
frame_id=frame_id,
mono_t=mono_t,
wall_t=wall_t,
valid=True,
invalid_reason="",
fn_raw=raw[0],
fx_raw=raw[1],
fy_raw=raw[2],
fn_n=newton[0],
fx_n=newton[1],
fy_n=newton[2],
calibrated_axes=axes,
calibration_version=version,
)
def axes_without_newton(sample: RecordSample) -> tuple[str, ...]:
"""Axes that produced no newton value — used by the UI to explain blanks."""
return tuple(axis for axis in AXES if not sample.has_newton(axis))
def plottable_mask(values: Iterable[float], valid: Iterable[bool]) -> list[bool]:
"""Shared visibility rule: a point is drawn only when valid *and* finite.
Every drawing path uses this so that a stale-but-finite value cannot reappear
in one view after being filtered out of another.
"""
return [
bool(is_valid) and math.isfinite(float(value))
for value, is_valid in zip(values, valid)
]
def plottable_axis_mask(values, valid) -> "np.ndarray":
"""``(n, axes)`` mask: one column per axis, decided for that axis alone.
The rule has to be per axis. A single-axis calibration is legitimate — the
device may have a normal-force curve and no shear curves — and an all-axes
rule hid a perfectly good FN because FX and FY had no newton value.
"""
import numpy as np
block = np.asarray(values, dtype=np.float64).reshape(-1, 3)
validity = np.asarray(valid, dtype=bool).reshape(-1)
finite = np.isfinite(block)
if validity.size != block.shape[0]:
return finite
return finite & validity[:, None]
@@ -0,0 +1,97 @@
"""The video side of a recording: one rolling MP4 segment at a time.
Kept apart from the CSV writer on purpose. The two have different failure modes —
a full disk breaks both, but a codec that refuses to open only breaks this one —
and the recorder's byte budget is about *these* frames, not about the rows.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Optional
import cv2
logger = logging.getLogger(__name__)
class VideoSegments:
"""Writes raw frames into ``raw_NNNN.mp4`` files, rolling on a time budget."""
def __init__(self, directory: Path, *, codec: str, fps: float, segment_seconds: float):
self.directory = Path(directory)
self.codec = str(codec)
self.fps = max(1.0, float(fps))
self.segment_seconds = float(segment_seconds)
self.segments = 0
self.video_frames = 0
self.frame_shape: Optional[tuple[int, int]] = None
self._writer: Optional[cv2.VideoWriter] = None
self._index = 0
self._frames_in_segment = 0
self._started = 0.0
# ------------------------------------------------------------------ writing
def write(self, frame) -> tuple[int, int]:
"""Append one frame. Returns ``(segment, frame_in_segment)``."""
if self.frame_shape is None:
self.frame_shape = (int(frame.shape[1]), int(frame.shape[0]))
if self._should_roll():
self._close_segment()
if self._writer is None:
self._open_segment()
self._writer.write(frame)
self._frames_in_segment += 1
self.video_frames += 1
return (self._index, self._frames_in_segment)
def write_sequence(self, frames) -> int:
"""Write a run of frames as one standalone segment. Returns the count."""
frames = [frame for frame in frames if frame is not None]
if not frames:
return 0
self.frame_shape = (int(frames[0].shape[1]), int(frames[0].shape[0]))
name = "preroll_0001.mp4"
writer = self._open_writer(self.directory / name)
written = 0
try:
for frame in frames:
writer.write(frame)
written += 1
finally:
writer.release()
return written
# ----------------------------------------------------------------- segments
def _should_roll(self) -> bool:
if self._writer is None:
return False
if self.segment_seconds <= 0:
return False
return (time.perf_counter() - self._started) >= self.segment_seconds
def _open_writer(self, path: Path) -> cv2.VideoWriter:
fourcc = cv2.VideoWriter_fourcc(*self.codec)
writer = cv2.VideoWriter(str(path), fourcc, self.fps, self.frame_shape)
if not writer.isOpened():
raise OSError(f"could not open video writer for {path.name}")
return writer
def _open_segment(self) -> None:
self._index += 1
path = self.directory / f"raw_{self._index:04d}.mp4"
self._writer = self._open_writer(path)
self._frames_in_segment = 0
self._started = time.perf_counter()
self.segments = self._index
def _close_segment(self) -> None:
if self._writer is None:
return
self._writer.release()
self._writer = None
def close(self) -> None:
self._close_segment()
@@ -1,23 +1,51 @@
{
"window_bg": "#121316",
"panel_bg": "#1a1c20",
"panel_border": "#2a2d33",
"text_primary": "#e8eaed",
"text_secondary": "#9aa0a6",
"accent": "#536dce",
"accent_hover": "#6b82d9",
"button_bg": "#2a2d33",
"button_hover": "#3a3f47",
"input_bg": "#0f1012",
"input_border": "#3a3f47",
"menu_bg": "#1a1c20",
"menu_hover": "#2a2d33",
"plot_fn": "#8ab4f8",
"plot_fx": "#34a853",
"plot_fy": "#fbbc04",
"plot_bg": "#121316",
"plot_grid": "#2a2d33",
"arrow_canvas": "#121316",
"danger": "#ea4335",
"success": "#34a853"
"window_bg": "#0E1320",
"panel_bg": "#161C2A",
"panel_border": "#28303F",
"toolbar_bg": "#161C2A",
"card_bg": "#1B2231",
"card_border": "#2E3748",
"separator": "#28303F",
"input_bg": "#0B0F19",
"input_border": "#66739A",
"button_bg": "#28303F",
"button_hover": "#333D50",
"menu_bg": "#161C2A",
"menu_hover": "#28303F",
"table_header_bg": "#1F2635",
"table_row_alt": "#1A2130",
"table_selected": "#2C3A63",
"scrollbar_bg": "#161C2A",
"scrollbar_handle": "#627092",
"scrollbar_handle_hover": "#6E7C9E",
"plot_bg": "#11161F",
"plot_grid": "#28303F",
"plot_fn": "#6FA8FF",
"plot_fx": "#FF9A4D",
"plot_fy": "#7CE0A0",
"text_primary": "#E6EAF2",
"text_secondary": "#9AA4B8",
"text_muted": "#838FA6",
"hint_text": "#838FA6",
"accent": "#4D62BE",
"accent_hover": "#5770CE",
"accent_text": "#FFFFFF",
"focus_ring": "#7C92E8",
"danger": "#EE6B60",
"danger_hover": "#F0796E",
"danger_fill": "#CC4438",
"danger_fill_hover": "#BE3A31",
"danger_text": "#FFFFFF",
"warning": "#F0B429",
"info": "#8AA6F2",
"success": "#4FC08D",
"status_idle": "#9AA4B8",
"status_connecting": "#F0B429",
"status_running": "#4FC08D",
"status_error": "#EE6B60",
"brand_bar_bg": "#12315D",
"brand_bar_fg": "#FFFFFF",
"brand_bar_fg_muted": "#B9C8E4",
"arrow_canvas": "#0B1830",
"flow_ramp": "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
}
@@ -1,23 +1,51 @@
{
"window_bg": "#f5f6f8",
"panel_bg": "#ffffff",
"panel_border": "#d0d4dc",
"text_primary": "#1a1c20",
"text_secondary": "#5f6368",
"accent": "#536dce",
"accent_hover": "#3f56b5",
"button_bg": "#e8eaed",
"button_hover": "#d0d4dc",
"input_bg": "#ffffff",
"input_border": "#c0c4cc",
"menu_bg": "#ffffff",
"menu_hover": "#e8eaed",
"plot_fn": "#1a73e8",
"plot_fx": "#188038",
"plot_fy": "#f9ab00",
"plot_bg": "#ffffff",
"plot_grid": "#e0e3e8",
"arrow_canvas": "#eef0f3",
"danger": "#d93025",
"success": "#188038"
"window_bg": "#E1E6EF",
"panel_bg": "#FFFFFF",
"panel_border": "#CFD7E4",
"toolbar_bg": "#EDF1F8",
"card_bg": "#F4F7FC",
"card_border": "#D5DCE8",
"separator": "#D8DFEA",
"input_bg": "#FFFFFF",
"input_border": "#7C8AA4",
"button_bg": "#F6F8FD",
"button_hover": "#E7EDF9",
"menu_bg": "#FFFFFF",
"menu_hover": "#EDF1F8",
"table_header_bg": "#EEF2F9",
"table_row_alt": "#F7F9FD",
"table_selected": "#D7E2FA",
"scrollbar_bg": "#E6EBF3",
"scrollbar_handle": "#78839A",
"scrollbar_handle_hover": "#6A7590",
"plot_bg": "#F8FAFD",
"plot_grid": "#DFE5EE",
"plot_fn": "#003893",
"plot_fx": "#B45309",
"plot_fy": "#2E7D32",
"text_primary": "#141A24",
"text_secondary": "#4A5566",
"text_muted": "#5C6678",
"hint_text": "#5C6678",
"accent": "#003893",
"accent_hover": "#4D62BE",
"accent_text": "#FFFFFF",
"focus_ring": "#4D62BE",
"danger": "#C7362B",
"danger_hover": "#B93227",
"danger_fill": "#C7362B",
"danger_fill_hover": "#B93227",
"danger_text": "#FFFFFF",
"warning": "#8A5A00",
"info": "#3A55B8",
"success": "#1F7A4D",
"status_idle": "#4A5566",
"status_connecting": "#8A5A00",
"status_running": "#1F7A4D",
"status_error": "#C7362B",
"brand_bar_bg": "#12315D",
"brand_bar_fg": "#FFFFFF",
"brand_bar_fg_muted": "#B9C8E4",
"arrow_canvas": "#12315D",
"flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
}

Some files were not shown because too many files have changed in this diff Show More