From 7607653c18c0272a8d1600097ebfb5efc781fa43 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sat, 17 Oct 2026 12:30:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Autocal=20=E5=BD=95?= =?UTF-8?q?=E5=83=8F=E6=95=85=E9=9A=9C=E5=A4=84=E7=90=86=E3=80=81=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E5=9B=9E=E6=94=BE=E4=B8=8E=E6=B1=87=E6=80=BB=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/calibration-data-format.zh.md | 2 + .../recording-20261017-110144/candidate.json | 611 + .../recording-workflow.delivery.json | 17 + .../recording-workflow.html | 13926 ++++++++++++++++ .../recording-20261017-110144/snapshot.json | 80 + .../working-tree.patch | 230 + .../docs/recording-summary-contract.zh.md | 4 +- apps/autocal/docs/recording-workflow.zh.md | 11 + apps/autocal/src/orisys_autocal/multipoint.py | 7 + apps/autocal/src/orisys_autocal/summary.py | 9 +- .../ui/record_overview_dialog.py | 4 +- .../ui/session_review_dialog.py | 4 +- .../orisys_autocal/ui/video_review_dialog.py | 17 +- .../src/orisys_autocal/video_review.py | 30 +- .../tests/test_multipoint_recording.py | 34 + apps/autocal/tests/test_summary_fit.py | 16 + apps/autocal/tests/test_video_review.py | 33 + .../autocal/tests/test_video_review_dialog.py | 30 + 18 files changed, 15042 insertions(+), 23 deletions(-) create mode 100644 apps/autocal/docs/diagrams/recording-20261017-110144/candidate.json create mode 100644 apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.delivery.json create mode 100644 apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.html create mode 100644 apps/autocal/docs/diagrams/recording-20261017-110144/snapshot.json create mode 100644 apps/autocal/docs/diagrams/recording-20261017-110144/working-tree.patch create mode 100644 apps/autocal/docs/recording-workflow.zh.md diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index b519317..4dad02f 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -104,6 +104,8 @@ finger_F001_<时间>_<标识>/ `summary_export` 写 `derived/summary_*/` 与 `summary-latest.json`,按 `hold_id`(`reference.csv` 数据行序号)区分重测,不自动标记稳定,不把 raw 与 N 混用。 +见[录像工作流](recording-workflow.zh.md)。 + ### 多点会话格式 多点标定按测点组织(`sites//repeat-00N/`),复核输入见[多点标定会话格式](multipoint-data-format.zh.md)。 diff --git a/apps/autocal/docs/diagrams/recording-20261017-110144/candidate.json b/apps/autocal/docs/diagrams/recording-20261017-110144/candidate.json new file mode 100644 index 0000000..0be872f --- /dev/null +++ b/apps/autocal/docs/diagrams/recording-20261017-110144/candidate.json @@ -0,0 +1,611 @@ +{ + "schema_version": 2, + "diagram_type": "workflow", + "meta": { + "title": "Autocal 当前录像与复核流程", + "locale": "zh-CN", + "quality_profile": "showcase", + "output": "apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.html", + "repository": { + "url": "ssh://git@892887cvmk08.aicp.io:24928/orisys-tech/orisys-app.git", + "revision": "19426c3c4a648ca7f4d3785e2e11b1bf124a8e31", + "link_mode": "local-only" + }, + "legend": { + "entries": { + "frontend": { + "label": "操作界面" + }, + "backend": { + "label": "软件处理" + }, + "database": { + "label": "数据与文件" + }, + "security": { + "label": "身份与完整性" + }, + "messagebus": { + "label": "帧队列" + }, + "external": { + "label": "图像来源" + } + } + } + }, + "lanes": [ + { + "id": "operator", + "label": "操作与试验状态" + }, + { + "id": "record", + "label": "逐帧采集与后台写入" + }, + { + "id": "exceptions", + "label": "异常证据与完整性", + "variant": "exception" + }, + { + "id": "failed", + "label": "已关闭的失败记录", + "variant": "exception" + }, + { + "id": "review", + "label": "离线复核与操作员汇总" + } + ], + "mainPath": [ + "start", + "camera", + "guard", + "queue", + "writer", + "closing", + "verdict" + ], + "nodes": [ + { + "id": "start", + "lane": "operator", + "col": 0, + "type": "frontend", + "label": "开始本次试验", + "sublabel": "固定测点与重复身份", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 1025, + "end_line": 1106, + "label": "HEAD:登记 repeat" + } + ], + "width": 156 + }, + { + "id": "holds", + "lane": "operator", + "col": 4, + "type": "frontend", + "label": "人工确认载荷", + "sublabel": "保持窗口与事件时间", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 707, + "end_line": 734, + "label": "HEAD:保持事件" + } + ], + "width": 156 + }, + { + "id": "closing", + "lane": "operator", + "col": 5, + "type": "backend", + "label": "异步停止与排空", + "sublabel": "移除 sink 后轮询收尾", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 1146, + "end_line": 1187, + "label": "HEAD:异步收尾" + } + ], + "width": 156 + }, + { + "id": "verdict", + "lane": "exceptions", + "col": 5, + "type": "security", + "label": "判定证据完整性", + "sublabel": "report 与视频探测", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/capture_guard.py", + "line": 149, + "end_line": 225 + } + ], + "width": 156 + }, + { + "id": "camera", + "lane": "record", + "col": 0, + "type": "external", + "label": "相机输入画面", + "sublabel": "帧号与主机采集时间", + "sources": [ + { + "path": "shared/src/orisys_app_shared/acquisition/finger_worker.py", + "line": 945, + "end_line": 1010 + } + ], + "width": 156 + }, + { + "id": "guard", + "lane": "record", + "col": 1, + "type": "security", + "label": "检查来源状态", + "sublabel": "检测断流与来源重启", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/capture_guard.py", + "line": 49, + "end_line": 78 + }, + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 1288, + "end_line": 1300, + "label": "HEAD:来源健康检查" + } + ], + "width": 156 + }, + { + "id": "queue", + "lane": "record", + "col": 2, + "type": "messagebus", + "label": "有界录像队列", + "sublabel": "采集线程不等待编码", + "sources": [ + { + "path": "shared/src/orisys_app_shared/record/experiment.py", + "line": 549, + "end_line": 641 + } + ], + "width": 156 + }, + { + "id": "writer", + "lane": "record", + "col": 3, + "type": "backend", + "label": "后台写入视频", + "sublabel": "MP4 与逐帧 CSV", + "sources": [ + { + "path": "shared/src/orisys_app_shared/record/experiment.py", + "line": 737, + "end_line": 828 + } + ], + "width": 156 + }, + { + "id": "raw", + "lane": "record", + "col": 4, + "type": "database", + "label": "原始试验目录", + "sublabel": "视频、力、参考与事件", + "sources": [ + { + "path": "shared/src/orisys_app_shared/record/experiment.py", + "line": 1, + "end_line": 24 + }, + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 1089, + "end_line": 1106, + "label": "HEAD:reference/events" + } + ], + "width": 156 + }, + { + "id": "fault", + "lane": "exceptions", + "col": 3, + "type": "security", + "label": "中止当前试验", + "sublabel": "断流、重启或写入失败", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 806, + "end_line": 830, + "label": "HEAD:中止路径" + }, + { + "path": "shared/src/orisys_app_shared/record/experiment.py", + "line": 897, + "end_line": 921, + "label": "写线程失败状态" + } + ], + "width": 156 + }, + { + "id": "incomplete", + "lane": "failed", + "col": 5, + "type": "database", + "label": "保留不完整证据", + "sublabel": "记录原因,收尾后可诊断", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/capture_guard.py", + "line": 194, + "end_line": 225 + }, + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "line": 1231, + "end_line": 1262, + "label": "HEAD:保留失败记录" + } + ], + "width": 156 + }, + { + "id": "reopen", + "lane": "review", + "col": 0, + "type": "frontend", + "label": "打开标定记录", + "sublabel": "成功与已关闭失败记录", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/session_review.py", + "line": 408, + "end_line": 435 + } + ], + "width": 156 + }, + { + "id": "frames", + "lane": "review", + "col": 1, + "type": "backend", + "label": "按采集时间定位", + "sublabel": "帧身份与分段位置关联", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/video_review.py", + "line": 126, + "end_line": 187, + "label": "HEAD:索引定位" + } + ], + "width": 156 + }, + { + "id": "selection", + "lane": "review", + "col": 2, + "type": "frontend", + "label": "检查与选择区间", + "sublabel": "对应画面与原始力曲线", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/session_review.py", + "line": 310, + "end_line": 366 + } + ], + "width": 156 + }, + { + "id": "save", + "lane": "review", + "col": 3, + "type": "database", + "label": "保存复核快照", + "sublabel": "新版本,不覆盖采集文件", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/session_review.py", + "line": 598, + "end_line": 622 + } + ], + "width": 156 + }, + { + "id": "summary", + "lane": "review", + "col": 4, + "type": "backend", + "label": "计算窗口与灵敏度", + "sublabel": "候选与确认分别统计", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/summary.py", + "line": 447, + "end_line": 500, + "label": "HEAD:分集合拟合" + } + ], + "width": 156 + }, + { + "id": "publish", + "lane": "review", + "col": 5, + "type": "database", + "label": "发布操作员汇总", + "sublabel": "报告、两表与最新指针", + "sources": [ + { + "path": "apps/autocal/src/orisys_autocal/summary_export.py", + "line": 308, + "end_line": 366 + } + ], + "width": 156 + } + ], + "edges": [ + { + "id": "begin-capture", + "from": "start", + "to": "camera", + "variant": "emphasis" + }, + { + "id": "begin-holds", + "from": "start", + "to": "holds", + "label": "逐档确认", + "role": "branch" + }, + { + "id": "capture-guard", + "from": "camera", + "to": "guard", + "variant": "emphasis" + }, + { + "id": "guard-queue", + "from": "guard", + "to": "queue", + "variant": "emphasis" + }, + { + "id": "queue-write", + "from": "queue", + "to": "writer", + "variant": "emphasis", + "role": "async" + }, + { + "id": "write-files", + "from": "writer", + "to": "raw", + "label": "写盘" + }, + { + "id": "holds-files", + "from": "holds", + "to": "raw", + "label": "参考与事件", + "role": "branch", + "variant": "dashed" + }, + { + "id": "holds-close", + "from": "holds", + "to": "closing", + "label": "完成或中止", + "fromSide": "top", + "toSide": "top", + "via": [ + [ + 872, + 65 + ], + [ + 1056, + 65 + ] + ] + }, + { + "id": "write-close", + "from": "writer", + "to": "closing", + "label": "结束后收尾", + "role": "main" + }, + { + "id": "close-verdict", + "from": "closing", + "to": "verdict", + "label": "writer 终态", + "variant": "emphasis", + "fromSide": "right", + "toSide": "top", + "via": [ + [ + 1204, + 119 + ], + [ + 1204, + 315 + ], + [ + 1056, + 315 + ] + ] + }, + { + "id": "write-fault", + "from": "writer", + "to": "fault", + "label": "异常", + "role": "error", + "variant": "security" + }, + { + "id": "fault-close", + "from": "fault", + "to": "closing", + "label": "停止后排空", + "role": "error", + "variant": "security", + "fromSide": "right", + "toSide": "bottom", + "via": [ + [ + 968, + 367 + ], + [ + 968, + 175 + ], + [ + 1056, + 175 + ] + ] + }, + { + "id": "verdict-failed", + "from": "verdict", + "to": "incomplete", + "label": "未通过", + "role": "error", + "variant": "security" + }, + { + "id": "verdict-review", + "from": "verdict", + "to": "reopen", + "label": "收尾后复核", + "role": "return" + }, + { + "id": "reopen-frames", + "from": "reopen", + "to": "frames", + "variant": "emphasis" + }, + { + "id": "frames-select", + "from": "frames", + "to": "selection", + "variant": "emphasis" + }, + { + "id": "select-save", + "from": "selection", + "to": "save", + "variant": "emphasis" + }, + { + "id": "save-summary", + "from": "save", + "to": "summary", + "variant": "emphasis" + }, + { + "id": "summary-publish", + "from": "summary", + "to": "publish", + "variant": "emphasis" + }, + { + "id": "source-fault", + "from": "guard", + "to": "fault", + "label": "来源异常", + "role": "error", + "variant": "security", + "fromSide": "bottom", + "toSide": "left", + "via": [ + [ + 310, + 367 + ] + ] + }, + { + "id": "failed-review", + "from": "incomplete", + "to": "reopen", + "label": "诊断回放", + "role": "branch", + "variant": "dashed", + "fromSide": "left", + "toSide": "left", + "via": [ + [ + 26, + 491 + ], + [ + 26, + 615 + ] + ] + } + ], + "cards": [ + { + "dot": "cyan", + "title": "每次试验的一条证据链", + "items": [ + "连续保留零载、换档、加载与卸载;保持段不拆视频。", + "单次试验写 trials;多点试验写 sites/repeat/capture。" + ] + }, + { + "dot": "emerald", + "title": "复核与汇总", + "items": [ + "用主机采集时间关联窗口,用帧索引定位视频。", + "回放仅解码所选区间;不累积保存所有图像。", + "结束整场可先发布候选汇总,保存复核后发布新版本。" + ] + }, + { + "dot": "amber", + "title": "版本与验证边界", + "items": [ + "当前图包含五项未提交修复;源码链接对应 HEAD 基线的模块归属。", + "工作树补丁与文件哈希见同目录 snapshot.json。", + "录像支持视觉复核;不代表独立精度验证或 FT 物理同步。" + ] + } + ] +} diff --git a/apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.delivery.json b/apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.delivery.json new file mode 100644 index 0000000..673537d --- /dev/null +++ b/apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.delivery.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "receiptId": "5aac4630-c1c0-45fa-9b13-87c930df7706", + "status": "current", + "command": "deliver", + "type": "workflow", + "input": "D:\\orisys_ws\\orisys-app\\apps\\autocal\\docs\\diagrams\\recording-20261017-110144\\candidate.json", + "output": "D:\\orisys_ws\\orisys-app\\apps\\autocal\\docs\\diagrams\\recording-20261017-110144\\recording-workflow.html", + "specification": { + "sha256": "6778c4a565b36a780a741a791aa59580682eb6c3d61092b1da110319faff96c1", + "bytes": 14323 + }, + "artifact": { + "sha256": "33f8f732ff5f7540d55690a3a8f7a2115b247ade43f3918bdd0654bc87f90e91", + "bytes": 779934 + } +} diff --git a/apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.html b/apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.html new file mode 100644 index 0000000..fa607ae --- /dev/null +++ b/apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.html @@ -0,0 +1,13926 @@ + + + + + + + + Autocal 当前录像与复核流程 + + + + + + + + +
+ +
+
+
+

Autocal 当前录像与复核流程

+
+
+ + + + +
+ + Autocal 当前录像与复核流程 + 由 Archify 生成的工作流图。 + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 操作与试验状态 + + + 02 / 逐帧采集与后台写入 + + + + EX / 异常证据与完整性 + + + + EX / 已关闭的失败记录 + + + 05 / 离线复核与操作员汇总 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 开始本次试验 · 固定测点与重复身份 · 操作与试验状态 + + + + 开始本次试验 + 固定测点与重复身份 + + + + 人工确认载荷 · 保持窗口与事件时间 · 操作与试验状态 + + + + 人工确认载荷 + 保持窗口与事件时间 + + + + 异步停止与排空 · 移除 sink 后轮询收尾 · 操作与试验状态 + + + + 异步停止与排空 + 移除 sink 后轮询收尾 + + + + 相机输入画面 · 帧号与主机采集时间 · 逐帧采集与后台写入 + + + + 相机输入画面 + 帧号与主机采集时间 + + + + 检查来源状态 · 检测断流与来源重启 · 逐帧采集与后台写入 + + + + 检查来源状态 + 检测断流与来源重启 + + + + 有界录像队列 · 采集线程不等待编码 · 逐帧采集与后台写入 + + + + 有界录像队列 + 采集线程不等待编码 + + + + 后台写入视频 · MP4 与逐帧 CSV · 逐帧采集与后台写入 + + + + 后台写入视频 + MP4 与逐帧 CSV + + + + 原始试验目录 · 视频、力、参考与事件 · 逐帧采集与后台写入 + + + + 原始试验目录 + 视频、力、参考与事件 + + + + 中止当前试验 · 断流、重启或写入失败 · 异常证据与完整性 + + + + 中止当前试验 + 断流、重启或写入失败 + + + + 判定证据完整性 · report 与视频探测 · 异常证据与完整性 + + + + 判定证据完整性 + report 与视频探测 + + + + 保留不完整证据 · 记录原因,收尾后可诊断 · 已关闭的失败记录 + + + + 保留不完整证据 + 记录原因,收尾后可诊断 + + + + 打开标定记录 · 成功与已关闭失败记录 · 离线复核与操作员汇总 + + + + 打开标定记录 + 成功与已关闭失败记录 + + + + 按采集时间定位 · 帧身份与分段位置关联 · 离线复核与操作员汇总 + + + + 按采集时间定位 + 帧身份与分段位置关联 + + + + 检查与选择区间 · 对应画面与原始力曲线 · 离线复核与操作员汇总 + + + + 检查与选择区间 + 对应画面与原始力曲线 + + + + 保存复核快照 · 新版本,不覆盖采集文件 · 离线复核与操作员汇总 + + + + 保存复核快照 + 新版本,不覆盖采集文件 + + + + 计算窗口与灵敏度 · 候选与确认分别统计 · 离线复核与操作员汇总 + + + + 计算窗口与灵敏度 + 候选与确认分别统计 + + + + 发布操作员汇总 · 报告、两表与最新指针 · 离线复核与操作员汇总 + + + + 发布操作员汇总 + 报告、两表与最新指针 + + + + + + + 逐档确认 + + + + + writer 终态 + + + + 诊断回放 + + + + 停止后排空 + + + + + + 完成或中止 + + + + 参考与事件 + + + + + + + + 来源异常 + + + + + 未通过 + + + + 收尾后复核 + + + + 结束后收尾 + + + + 异常 + + + + 写盘 + + + + + 图例 + + + 操作界面 + + + + 软件处理 + + + + 身份与完整性 + + + + 帧队列 + + + + 数据与文件 + + + + 图像来源 + + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/apps/autocal/docs/diagrams/recording-20261017-110144/snapshot.json b/apps/autocal/docs/diagrams/recording-20261017-110144/snapshot.json new file mode 100644 index 0000000..d3b1030 --- /dev/null +++ b/apps/autocal/docs/diagrams/recording-20261017-110144/snapshot.json @@ -0,0 +1,80 @@ +{ + "format_version": "autocal-workflow-snapshot-1.0", + "baseline_revision": "19426c3c4a648ca7f4d3785e2e11b1bf124a8e31", + "source_links": "HEAD module evidence; current fixes are described by working-tree.patch and file hashes", + "patch": "working-tree.patch", + "patch_sha256": "dce358a6882f44e02069ebcae65707503018958d9884aab534c8702402bc36db", + "candidate_sha256": "6778c4a565b36a780a741a791aa59580682eb6c3d61092b1da110319faff96c1", + "html_sha256": "33f8f732ff5f7540d55690a3a8f7a2115b247ade43f3918bdd0654bc87f90e91", + "files": [ + { + "path": "apps/autocal/src/orisys_autocal/capture_guard.py", + "head_sha256": "3db256e207cff524074b80b1725fa9f3551b63ce56de4601d7a0a6a56b0668f1", + "worktree_sha256": "3db256e207cff524074b80b1725fa9f3551b63ce56de4601d7a0a6a56b0668f1" + }, + { + "path": "apps/autocal/src/orisys_autocal/multipoint.py", + "head_sha256": "345eeaa41d1e12b3fa237f8eea239d9955716b20b23f1ba2d5bbb4e49d39968b", + "worktree_sha256": "fad7e9bbdc41472806972be45eea346f7d14eb72fb2f2f7c74d7e07108586ebb" + }, + { + "path": "apps/autocal/src/orisys_autocal/session_review.py", + "head_sha256": "61d01756593629c7b6282dbaf3adbd043a94b4b7a30468c52e61e55cb778fc3e", + "worktree_sha256": "21619b4185abcb15cbb6e508acfcbcc63b618f590d4107cbbc687fc344096318" + }, + { + "path": "apps/autocal/src/orisys_autocal/summary.py", + "head_sha256": "84fd0ff5f6c3465f0d4a3526178a37c32b64304e62cdd10cbc88f0c52e5202f2", + "worktree_sha256": "d15e7f00e944305f730075fd2e302cda4e44b0aed14bebc6b0d473c4c5ec0f2f" + }, + { + "path": "apps/autocal/src/orisys_autocal/summary_export.py", + "head_sha256": "a49ac5d0c55eed301d413af7b0b35d46fc4ba9bec9bf0a05fb421831b5102e9f", + "worktree_sha256": "a49ac5d0c55eed301d413af7b0b35d46fc4ba9bec9bf0a05fb421831b5102e9f" + }, + { + "path": "apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py", + "head_sha256": "5e4b204cc1304ac3d9235d2c4d0f287a45b86c3fc3c24dc6ad141a958a4c366a", + "worktree_sha256": "472dc129a68fad5ec40673185a9ce7549a7310b817b20a5636379ded4e8d57e6" + }, + { + "path": "apps/autocal/src/orisys_autocal/ui/session_review_dialog.py", + "head_sha256": "b9fe09f64abc5f508a2f47c877faee3e2ec7827dd1719dac77aeba685a12d408", + "worktree_sha256": "fe50e1b3b9fc1edb1b64c36fc44997416db818c10363341021598d46864f8640" + }, + { + "path": "apps/autocal/src/orisys_autocal/ui/video_review_dialog.py", + "head_sha256": "96d363c5fcfee9fc11367273ddcf36f2730595d3c957e62d91fe30946ce94ce9", + "worktree_sha256": "f03648ac69197e9bb5f91a425e8d8b376f568396f3ce81f6d90007a6c092ace9" + }, + { + "path": "apps/autocal/src/orisys_autocal/video_review.py", + "head_sha256": "44233205b41a075f8abd89f4a1f01c6079dd092d739d1842bc66cde19e2b65e1", + "worktree_sha256": "18992c25777afd8a241545693c63d91b321de7c3cb08690b606fcc54fb6d192a" + }, + { + "path": "shared/src/orisys_app_shared/acquisition/finger_worker.py", + "head_sha256": "a9bff46aef6191a99efdc019aff4d4a4908c2a39e9bfcb0c4dc29ec6a6909a33", + "worktree_sha256": "db9e59c728fef241e5fdfde6cdf683c70c9785efea1dc2139c0080695ecd110b" + }, + { + "path": "shared/src/orisys_app_shared/record/experiment.py", + "head_sha256": "3f0c6fba0cbfd711d4b9db8a62aa325887073ef08b29f7561d24de27e9a666a2", + "worktree_sha256": "405abe333319a8f0b9aeb6b60a15e2d7cedbfdcec9a84d82a3685c4ec23008ed" + } + ], + "verification": { + "regression_before": "5 failed", + "focused_after": "45 passed", + "autocal_after": "572 passed; 0 skipped", + "lint": "pass", + "archify_gates": { + "validate": "pass", + "deliver": "pass", + "check": "pass", + "browser-check": "pass" + }, + "visual_review": "light and dark screenshots inspected" + }, + "scope": "software only; no physical camera, controller or load operation" +} diff --git a/apps/autocal/docs/diagrams/recording-20261017-110144/working-tree.patch b/apps/autocal/docs/diagrams/recording-20261017-110144/working-tree.patch new file mode 100644 index 0000000..3006b25 --- /dev/null +++ b/apps/autocal/docs/diagrams/recording-20261017-110144/working-tree.patch @@ -0,0 +1,230 @@ +diff --git a/apps/autocal/src/orisys_autocal/multipoint.py b/apps/autocal/src/orisys_autocal/multipoint.py +index c0bfc92..e7461f4 100644 +--- a/apps/autocal/src/orisys_autocal/multipoint.py ++++ b/apps/autocal/src/orisys_autocal/multipoint.py +@@ -1298,6 +1298,10 @@ class Campaign: + # called complete evidence. + self._source_stopped = True + self.stop("the image source stopped") ++ elif self._recorder is not None and ( ++ not self._recorder.is_recording or self._recorder.stats().write_errors ++ ): ++ self.stop("the recording writer stopped or failed") + if self.capture_state == "closing": + self._finalise_close() + return { +@@ -1329,6 +1333,9 @@ class Campaign: + self.capture_state == "recording" + and self._guard is not None + and not self._guard.failed ++ and self._recorder is not None ++ and self._recorder.is_recording ++ and not self._recorder.stats().write_errors + ) + + def capture_summary(self) -> dict: +diff --git a/apps/autocal/src/orisys_autocal/summary.py b/apps/autocal/src/orisys_autocal/summary.py +index e282a78..d8c5105 100644 +--- a/apps/autocal/src/orisys_autocal/summary.py ++++ b/apps/autocal/src/orisys_autocal/summary.py +@@ -502,18 +502,19 @@ def build_sensitivity(windows: list[dict]) -> list[dict]: + + def aggregate_sensitivity(rows: list[dict]) -> list[dict]: + """Mean and sample deviation of the per-trial slopes. Not a joint fit.""" +- groups: dict[tuple[str, str], list[dict]] = {} ++ groups: dict[tuple[str, str, str], list[dict]] = {} + for row in rows: + if row["status"] != "available" or row["slope_raw_per_N"] is None: + continue +- groups.setdefault((row["site_id"], row["component"]), []).append(row) ++ groups.setdefault((row["site_id"], row["component"], row["review_status"]), []).append(row) + result = [] +- for (site_id, component), group in sorted(groups.items()): ++ for (site_id, component, review_status), group in sorted(groups.items()): + slopes = [float(row["slope_raw_per_N"]) for row in group] + result.append({ + "site_id": site_id, + "component": component, +- "trials": len(slopes), ++ "review_status": review_status, ++ "trials": len({row["trial_id"] for row in group}), + "slope_mean_raw_per_N": float(np.mean(slopes)), + "slope_std_raw_per_N": float(np.std(slopes, ddof=1)) if len(slopes) >= 2 else None, + "window_count": sum(int(row["window_count"] or 0) for row in group), +diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py +index 23c9b74..e6f7c12 100644 +--- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py ++++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py +@@ -551,6 +551,7 @@ class RecordOverviewDialog(QDialog): + + def _video(self): + """Open the pictures of the selected point's current window.""" ++ from ..video_review import trial_is_closed + from .video_review_dialog import VideoReviewDialog + + index = self.table.currentRow() +@@ -571,8 +572,7 @@ class RecordOverviewDialog(QDialog): + (item for item in self.review.get("trials") or [] + if item["trial_id"] == window["trial_id"]), None, + ) +- if trial is None or trial.get("status") != "complete": +- # An unclosed trial still has the writer's file open. ++ if not trial_is_closed(self.directory, trial): + self.notice.setText(t("overview.guide_incomplete")) + return None + dialog = VideoReviewDialog( +diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +index 6992bac..5a25696 100644 +--- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py ++++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +@@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( + ) + + from ..session_review import review_session, save_review, load_review, reselect_review ++from ..video_review import trial_is_closed + from .review_job import ReviewJob + from .chart_helpers import add_value_axes, clear_chart + +@@ -231,8 +232,7 @@ class SessionReviewDialog(QDialog): + (item for item in self.review.get("trials") or [] + if item["trial_id"] == window["trial_id"]), None, + ) +- if trial is None or trial.get("status") != "complete": +- # Only a closed trial has a finished container to decode. ++ if not trial_is_closed(self.directory, trial): + self.message.setText(t("overview.guide_incomplete")) + return None + from .video_review_dialog import VideoReviewDialog +diff --git a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py +index fbf2547..cfe2242 100644 +--- a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py ++++ b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py +@@ -293,13 +293,18 @@ class VideoReviewDialog(QDialog): + window = self.window + if not window: + return +- origin = int(window["hold_start_host_mono_ns"]) ++ origin = int(window["t_start_host_mono_ns"]) ++ duration = max((int(window["t_end_host_mono_ns"]) - origin) / 1e9, 0.001) ++ selected_trace = [ ++ (stamp, value) for stamp, _frame, value in window.get("trace") or [] ++ if origin <= stamp < int(window["t_end_host_mono_ns"]) ++ ] + trace = QScatterSeries() + trace.setMarkerSize(3) + trace.setName(t("review.valid_samples")) + trace.replace([ + QPointF((stamp - origin) / 1e9, value) +- for stamp, _frame, value in window.get("trace") or [] ++ for stamp, value in selected_trace + ]) + self.curve.addSeries(trace) + self.curve_line = QLineSeries() +@@ -308,15 +313,15 @@ class VideoReviewDialog(QDialog): + reference = QLineSeries() + reference.setName(t("video.reference")) + reference.append(0.0, float(window.get("reference_n") or 0.0)) +- reference.append(max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001), ++ reference.append(duration, + float(window.get("reference_n") or 0.0)) + self.curve.addSeries(reference) + axis_x = QValueAxis() + axis_x.setTitleText(t("review.hold_time_axis")) +- axis_x.setRange(0, max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001)) ++ axis_x.setRange(0, duration) + axis_y = QValueAxis() + axis_y.setTitleText(t("review.raw_axis")) +- values = [value for _stamp, _frame, value in window.get("trace") or []] ++ values = [value for _stamp, value in selected_trace] + values.append(float(window.get("reference_n") or 0.0)) + low, high = (min(values), max(values)) if values else (0.0, 1.0) + margin = max((high - low) * 0.1, 0.1) +@@ -335,7 +340,7 @@ class VideoReviewDialog(QDialog): + return + window = self.window + frame = self.video.frames[index] +- origin = int(window["hold_start_host_mono_ns"]) ++ origin = int(window["t_start_host_mono_ns"]) + x = (frame.t_host_mono_ns - origin) / 1e9 + axis = self.curve.axes(Qt.Orientation.Vertical) + low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0) +diff --git a/apps/autocal/src/orisys_autocal/video_review.py b/apps/autocal/src/orisys_autocal/video_review.py +index 4391a74..ad15f97 100644 +--- a/apps/autocal/src/orisys_autocal/video_review.py ++++ b/apps/autocal/src/orisys_autocal/video_review.py +@@ -11,7 +11,8 @@ segment stays a reason. The decoder is released by :meth:`WindowVideo.close`. + + from __future__ import annotations + +-from dataclasses import dataclass, field ++import json ++from dataclasses import dataclass + from pathlib import Path + + import numpy as np +@@ -79,6 +80,24 @@ def _trial(review: dict, trial_id: str) -> dict | None: + return None + + ++def trial_is_closed(root: Path, trial: dict | None) -> bool: ++ """Closed failed evidence is replayable; a live writer's container is not.""" ++ if trial is None or trial.get("capture_status") in {"idle", "starting", "recording", "closing"}: ++ return False ++ if (trial.get("capture_report") or {}).get("completed") is True: ++ return True ++ if trial.get("status") == "complete": ++ return True ++ directory = resolve_capture_directory(root, trial_directory=str(trial.get("directory") or "")) ++ if directory is None: ++ return False ++ try: ++ metadata = json.loads((directory / "session.json").read_text(encoding="utf-8")) ++ except (OSError, ValueError): ++ return False ++ return bool(metadata.get("ended_at")) ++ ++ + def in_window_frames( + review: dict, *, trial_id: str, step_index: int + ) -> list[FrameRow] | str: +@@ -97,8 +116,8 @@ def in_window_frames( + ) + if directory is None: + return NO_FRAME_SOURCE +- start = int(window["hold_start_host_mono_ns"]) +- end = int(window["hold_end_host_mono_ns"]) ++ start = int(window.get("t_start_host_mono_ns", window["hold_start_host_mono_ns"])) ++ end = int(window.get("t_end_host_mono_ns", window["hold_end_host_mono_ns"])) + unique, _duplicated = unique_frames(read_frame_index(directory)) + frames = [ + frame for frame in unique.values() +@@ -205,7 +224,6 @@ class WindowVideo: + frames: list[FrameRow] + #: Index of the frame currently decoded, or -1 when the decoder was reset. + position: int = -1 +- _images: dict[int, np.ndarray] = field(default_factory=dict) + + @property + def frame_count(self) -> int: +@@ -214,8 +232,6 @@ class WindowVideo: + def frame(self, index: int) -> np.ndarray | None: + """Image for ``frames[index]``, or ``None`` when that frame has no picture.""" + index = max(0, min(int(index), self.frame_count - 1)) +- if index in self._images: +- return self._images[index] + row = self.frames[index] + try: + image, served = self.reader.video_frame_at(row.t_rel_s) +@@ -231,12 +247,10 @@ class WindowVideo: + self.position = -1 + return None + self.position = index +- self._images[index] = image + return image + + def release(self) -> None: + self.reader.close() +- self._images.clear() + self.position = -1 + + def close(self) -> None: diff --git a/apps/autocal/docs/recording-summary-contract.zh.md b/apps/autocal/docs/recording-summary-contract.zh.md index 2784903..8709fbf 100644 --- a/apps/autocal/docs/recording-summary-contract.zh.md +++ b/apps/autocal/docs/recording-summary-contract.zh.md @@ -84,7 +84,9 @@ idle → starting → recording → closing → complete `sensitivity_values.csv` 每点每 trial 每轴每种审核集合一行;纵向结构允许缺轴。含身份、`component,review_status,method,slope_raw_per_N,intercept_raw,R2,rmse_raw,endpoint_raw_per_N,initial_step_raw,reference_min_N,reference_max_N,window_count,status,reason`。导出保持双精度,不沿用旧表六位有效数字作为计算输入。 -方法固定为 `raw_on_reference_ols_v1`:以实际参考 N 为 x,原始窗口均值为 y,在同一 trial 内用初始零载和加载窗口等权拟合 `y=a*x+b`。卸载单独展示,不进入该拟合。至少两个不同参考力,否则 unavailable;不跨 trial 混合不同零点。报告按点汇总合格 trial 斜率的均值、样本标准差和数量,不把它冒充新的联合拟合,不平均 R²。 +方法固定为 `raw_on_reference_ols_v1`:以实际参考 N 为 x,原始窗口均值为 y,在同一 trial 内用初始零载和加载窗口等权拟合 `y=a*x+b`。卸载单独展示,不进入该拟合。至少两个不同参考力,否则 unavailable;不跨 trial 混合不同零点。 + +`sensitivity_summary` 按测点、分量和 `review_status` 分组,分别汇总候选/已确认 trial 斜率的均值与样本标准差,`trials` 统计唯一 trial_id;不混合两种审核集合、不计算联合拟合或平均 R²。 R² 为 `1-SSE/SST`;常量信号 SST=0 时为空并标记 flat_signal,零斜率不反推增益。endpoint 每 trial 用最高加载与初始零载的差除以参考力差;initial_step 为最低正加载均值减初始零载。跨 trial 只汇总这些量的均值及数量,缺配对或分母为零为空;不跨 trial 拼端点。 diff --git a/apps/autocal/docs/recording-workflow.zh.md b/apps/autocal/docs/recording-workflow.zh.md new file mode 100644 index 0000000..544ed4d --- /dev/null +++ b/apps/autocal/docs/recording-workflow.zh.md @@ -0,0 +1,11 @@ +# Autocal 当前录像工作流 + +状态:2026-10-17,软件实现说明,待实机验收。打开[交互工作流图](diagrams/recording-20261017-110144/recording-workflow.html)查看采集、异常收尾、视频复核和汇总发布;支持浅色/深色、放大及图片导出。 + +图中原有模块归属的源码引用固定在 `19426c3c4a648ca7f4d3785e2e11b1bf124a8e31`。本次五项修复尚未提交:写线程失败时中止、候选与确认结果分别汇总、按所选区间回放、允许已关闭失败记录诊断,以及取消全部解码图像的累积缓存。当前实现以[工作树快照清单](diagrams/recording-20261017-110144/snapshot.json)及其同目录补丁为依据,不将工作树字节冒充 HEAD 源码。 + +每 trial 连续保存零载、换档、加载和卸载画面,MP4 为有损输入录像。保持窗口依靠主机采集时间与帧身份关联,不能用容器 FPS 推算实验同步。操作员手动确认参考载荷;该流程不自动加载,也不证明 FT 物理同步。 + +原始文件继续使用[单次](calibration-data-format.zh.md)与[多点](multipoint-data-format.zh.md)目录。保持段不拆成新视频,复核与汇总另存版本;字段及计算方法见[汇总契约](recording-summary-contract.zh.md)。 + +验证:五个回归用例在修复前失败;修复后的相关 45 项测试和 Autocal 全量 572 项测试通过。Archify 的 showcase 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。 diff --git a/apps/autocal/src/orisys_autocal/multipoint.py b/apps/autocal/src/orisys_autocal/multipoint.py index c0bfc92..e7461f4 100644 --- a/apps/autocal/src/orisys_autocal/multipoint.py +++ b/apps/autocal/src/orisys_autocal/multipoint.py @@ -1298,6 +1298,10 @@ class Campaign: # called complete evidence. self._source_stopped = True self.stop("the image source stopped") + elif self._recorder is not None and ( + not self._recorder.is_recording or self._recorder.stats().write_errors + ): + self.stop("the recording writer stopped or failed") if self.capture_state == "closing": self._finalise_close() return { @@ -1329,6 +1333,9 @@ class Campaign: self.capture_state == "recording" and self._guard is not None and not self._guard.failed + and self._recorder is not None + and self._recorder.is_recording + and not self._recorder.stats().write_errors ) def capture_summary(self) -> dict: diff --git a/apps/autocal/src/orisys_autocal/summary.py b/apps/autocal/src/orisys_autocal/summary.py index e282a78..d8c5105 100644 --- a/apps/autocal/src/orisys_autocal/summary.py +++ b/apps/autocal/src/orisys_autocal/summary.py @@ -502,18 +502,19 @@ def build_sensitivity(windows: list[dict]) -> list[dict]: def aggregate_sensitivity(rows: list[dict]) -> list[dict]: """Mean and sample deviation of the per-trial slopes. Not a joint fit.""" - groups: dict[tuple[str, str], list[dict]] = {} + groups: dict[tuple[str, str, str], list[dict]] = {} for row in rows: if row["status"] != "available" or row["slope_raw_per_N"] is None: continue - groups.setdefault((row["site_id"], row["component"]), []).append(row) + groups.setdefault((row["site_id"], row["component"], row["review_status"]), []).append(row) result = [] - for (site_id, component), group in sorted(groups.items()): + for (site_id, component, review_status), group in sorted(groups.items()): slopes = [float(row["slope_raw_per_N"]) for row in group] result.append({ "site_id": site_id, "component": component, - "trials": len(slopes), + "review_status": review_status, + "trials": len({row["trial_id"] for row in group}), "slope_mean_raw_per_N": float(np.mean(slopes)), "slope_std_raw_per_N": float(np.std(slopes, ddof=1)) if len(slopes) >= 2 else None, "window_count": sum(int(row["window_count"] or 0) for row in group), diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py index 23c9b74..e6f7c12 100644 --- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py @@ -551,6 +551,7 @@ class RecordOverviewDialog(QDialog): def _video(self): """Open the pictures of the selected point's current window.""" + from ..video_review import trial_is_closed from .video_review_dialog import VideoReviewDialog index = self.table.currentRow() @@ -571,8 +572,7 @@ class RecordOverviewDialog(QDialog): (item for item in self.review.get("trials") or [] if item["trial_id"] == window["trial_id"]), None, ) - if trial is None or trial.get("status") != "complete": - # An unclosed trial still has the writer's file open. + if not trial_is_closed(self.directory, trial): self.notice.setText(t("overview.guide_incomplete")) return None dialog = VideoReviewDialog( diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py index 6992bac..5a25696 100644 --- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py @@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( ) from ..session_review import review_session, save_review, load_review, reselect_review +from ..video_review import trial_is_closed from .review_job import ReviewJob from .chart_helpers import add_value_axes, clear_chart @@ -231,8 +232,7 @@ class SessionReviewDialog(QDialog): (item for item in self.review.get("trials") or [] if item["trial_id"] == window["trial_id"]), None, ) - if trial is None or trial.get("status") != "complete": - # Only a closed trial has a finished container to decode. + if not trial_is_closed(self.directory, trial): self.message.setText(t("overview.guide_incomplete")) return None from .video_review_dialog import VideoReviewDialog diff --git a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py index fbf2547..cfe2242 100644 --- a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py @@ -293,13 +293,18 @@ class VideoReviewDialog(QDialog): window = self.window if not window: return - origin = int(window["hold_start_host_mono_ns"]) + origin = int(window["t_start_host_mono_ns"]) + duration = max((int(window["t_end_host_mono_ns"]) - origin) / 1e9, 0.001) + selected_trace = [ + (stamp, value) for stamp, _frame, value in window.get("trace") or [] + if origin <= stamp < int(window["t_end_host_mono_ns"]) + ] trace = QScatterSeries() trace.setMarkerSize(3) trace.setName(t("review.valid_samples")) trace.replace([ QPointF((stamp - origin) / 1e9, value) - for stamp, _frame, value in window.get("trace") or [] + for stamp, value in selected_trace ]) self.curve.addSeries(trace) self.curve_line = QLineSeries() @@ -308,15 +313,15 @@ class VideoReviewDialog(QDialog): reference = QLineSeries() reference.setName(t("video.reference")) reference.append(0.0, float(window.get("reference_n") or 0.0)) - reference.append(max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001), + reference.append(duration, float(window.get("reference_n") or 0.0)) self.curve.addSeries(reference) axis_x = QValueAxis() axis_x.setTitleText(t("review.hold_time_axis")) - axis_x.setRange(0, max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001)) + axis_x.setRange(0, duration) axis_y = QValueAxis() axis_y.setTitleText(t("review.raw_axis")) - values = [value for _stamp, _frame, value in window.get("trace") or []] + values = [value for _stamp, value in selected_trace] values.append(float(window.get("reference_n") or 0.0)) low, high = (min(values), max(values)) if values else (0.0, 1.0) margin = max((high - low) * 0.1, 0.1) @@ -335,7 +340,7 @@ class VideoReviewDialog(QDialog): return window = self.window frame = self.video.frames[index] - origin = int(window["hold_start_host_mono_ns"]) + origin = int(window["t_start_host_mono_ns"]) x = (frame.t_host_mono_ns - origin) / 1e9 axis = self.curve.axes(Qt.Orientation.Vertical) low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0) diff --git a/apps/autocal/src/orisys_autocal/video_review.py b/apps/autocal/src/orisys_autocal/video_review.py index 4391a74..ad15f97 100644 --- a/apps/autocal/src/orisys_autocal/video_review.py +++ b/apps/autocal/src/orisys_autocal/video_review.py @@ -11,7 +11,8 @@ segment stays a reason. The decoder is released by :meth:`WindowVideo.close`. from __future__ import annotations -from dataclasses import dataclass, field +import json +from dataclasses import dataclass from pathlib import Path import numpy as np @@ -79,6 +80,24 @@ def _trial(review: dict, trial_id: str) -> dict | None: return None +def trial_is_closed(root: Path, trial: dict | None) -> bool: + """Closed failed evidence is replayable; a live writer's container is not.""" + if trial is None or trial.get("capture_status") in {"idle", "starting", "recording", "closing"}: + return False + if (trial.get("capture_report") or {}).get("completed") is True: + return True + if trial.get("status") == "complete": + return True + directory = resolve_capture_directory(root, trial_directory=str(trial.get("directory") or "")) + if directory is None: + return False + try: + metadata = json.loads((directory / "session.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return bool(metadata.get("ended_at")) + + def in_window_frames( review: dict, *, trial_id: str, step_index: int ) -> list[FrameRow] | str: @@ -97,8 +116,8 @@ def in_window_frames( ) if directory is None: return NO_FRAME_SOURCE - start = int(window["hold_start_host_mono_ns"]) - end = int(window["hold_end_host_mono_ns"]) + start = int(window.get("t_start_host_mono_ns", window["hold_start_host_mono_ns"])) + end = int(window.get("t_end_host_mono_ns", window["hold_end_host_mono_ns"])) unique, _duplicated = unique_frames(read_frame_index(directory)) frames = [ frame for frame in unique.values() @@ -205,7 +224,6 @@ class WindowVideo: frames: list[FrameRow] #: Index of the frame currently decoded, or -1 when the decoder was reset. position: int = -1 - _images: dict[int, np.ndarray] = field(default_factory=dict) @property def frame_count(self) -> int: @@ -214,8 +232,6 @@ class WindowVideo: def frame(self, index: int) -> np.ndarray | None: """Image for ``frames[index]``, or ``None`` when that frame has no picture.""" index = max(0, min(int(index), self.frame_count - 1)) - if index in self._images: - return self._images[index] row = self.frames[index] try: image, served = self.reader.video_frame_at(row.t_rel_s) @@ -231,12 +247,10 @@ class WindowVideo: self.position = -1 return None self.position = index - self._images[index] = image return image def release(self) -> None: self.reader.close() - self._images.clear() self.position = -1 def close(self) -> None: diff --git a/apps/autocal/tests/test_multipoint_recording.py b/apps/autocal/tests/test_multipoint_recording.py index 7b1c05d..2b21253 100644 --- a/apps/autocal/tests/test_multipoint_recording.py +++ b/apps/autocal/tests/test_multipoint_recording.py @@ -193,6 +193,40 @@ def test_a_stopped_source_keeps_the_repeat_incomplete(tmp_path): assert session["status"] == "incomplete" +def test_a_failed_writer_aborts_while_the_camera_keeps_running(tmp_path, monkeypatch): + from orisys_app_shared.record.video_writer import VideoSegments + + def fail_write(self, frame): + raise OSError("encoder or disk failed") + + monkeypatch.setattr(VideoSegments, "write", fail_write) + campaign = _campaign(repeats=1) + root = _start(campaign, tmp_path) + worker = FakeWorker() + campaign.attach_capture(worker, fps=10) + recorder = campaign._recorder + try: + for _ in range(8): + worker.advance() + worker.emit() + time.sleep(0.02) + deadline = time.monotonic() + 3 + while recorder.is_recording and time.monotonic() < deadline: + time.sleep(0.005) + assert recorder.stats().failed + assert worker.is_running + assert not campaign.capture_ready + campaign.poll() + assert campaign.phase in {"closing", "aborted"} + _drain(campaign) + trial = _session(root)["trials"][0] + assert trial["capture_status"] == "incomplete" + assert trial["capture_reason"] == "write_error" + finally: + campaign.stop("test finished") + campaign.flush_close() + + def test_a_simulated_repeat_is_steps_complete_but_not_recorded_evidence(tmp_path): campaign = _campaign(repeats=1) root = campaign.start( diff --git a/apps/autocal/tests/test_summary_fit.py b/apps/autocal/tests/test_summary_fit.py index e615d68..238e711 100644 --- a/apps/autocal/tests/test_summary_fit.py +++ b/apps/autocal/tests/test_summary_fit.py @@ -164,3 +164,19 @@ def test_confirmed_and_candidate_windows_are_never_fitted_together(): assert by_review["candidate"]["slope_raw_per_N"] == pytest.approx(40.0) assert by_review["confirmed"]["window_count"] == 2 assert by_review["confirmed"]["slope_raw_per_N"] == pytest.approx(40.0) + + +def test_site_aggregation_keeps_review_sets_and_trial_counts_separate(): + from orisys_autocal.summary import aggregate_sensitivity, build_sensitivity + + windows = [ + _window(0, 0, branch="baseline"), _window(5, 500), + _window(10, 400, verified=True), _window(15, 600, verified=True), + ] + rows = [row for row in aggregate_sensitivity(build_sensitivity(windows)) + if row["component"] == "FN"] + assert len(rows) == 2 + by_review = {row["review_status"]: row for row in rows} + assert by_review["candidate"]["slope_mean_raw_per_N"] == pytest.approx(100) + assert by_review["confirmed"]["slope_mean_raw_per_N"] == pytest.approx(40) + assert all(row["trials"] == 1 and row["slope_std_raw_per_N"] is None for row in rows) diff --git a/apps/autocal/tests/test_video_review.py b/apps/autocal/tests/test_video_review.py index b68990e..5b0638c 100644 --- a/apps/autocal/tests/test_video_review.py +++ b/apps/autocal/tests/test_video_review.py @@ -205,6 +205,39 @@ def test_the_frame_list_is_bounded_by_the_hold(tmp_path): assert all(frame.in_video for frame in frames) +def test_confirmed_playback_uses_the_reviewers_selected_interval(tmp_path): + review = _review(tmp_path) + trial_id, step = _capture(review) + window = review["sites"][0]["windows"][1] + selected_start = window["trace"][1][0] + selected_end = window["hold_end_host_mono_ns"] + review = review_session(tmp_path / "rec", selections={f"{trial_id}:{step}": { + "t_start_host_mono_ns": selected_start, + "t_end_host_mono_ns": selected_end, + "stability_verified": True, + }}) + with open_window_video(review, trial_id=trial_id, step_index=step) as player: + assert len(player.frames) == 2 + assert all(selected_start <= frame.t_host_mono_ns < selected_end for frame in player.frames) + + +def test_playback_does_not_retain_previously_decoded_images(tmp_path): + import gc + import weakref + + review = _review(tmp_path) + trial_id, step = _capture(review) + refs = [] + with open_window_video(review, trial_id=trial_id, step_index=step) as player: + for index in range(player.frame_count): + image = player.frame(index) + assert image is not None + refs.append(weakref.ref(image)) + del image + gc.collect() + assert sum(ref() is not None for ref in refs) <= 1 + + def test_decode_failure_is_reported_rather_than_showing_a_neighbour(tmp_path): review = _review(tmp_path) root = tmp_path / "rec" diff --git a/apps/autocal/tests/test_video_review_dialog.py b/apps/autocal/tests/test_video_review_dialog.py index 10b2fb9..df46eed 100644 --- a/apps/autocal/tests/test_video_review_dialog.py +++ b/apps/autocal/tests/test_video_review_dialog.py @@ -147,3 +147,33 @@ def test_an_unclosed_trial_does_not_offer_the_video(qt_app, tmp_path): assert dialog.message.text() finally: dialog.close() + + +def test_a_closed_aborted_trial_can_be_replayed_for_diagnosis(qt_app, tmp_path): + import json + + from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog + from orisys_autocal.ui.session_review_dialog import SessionReviewDialog + + root = multipoint_session(tmp_path / "rec", trial_status="incomplete") + path = root / "session.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["trials"][0].update( + capture_status="incomplete", capture_report={"completed": True}) + path.write_text(json.dumps(manifest), encoding="utf-8") + review = review_session(root) + for kind in (SessionReviewDialog, RecordOverviewDialog): + dialog = kind(root, review=review) + opened = None + try: + if isinstance(dialog, RecordOverviewDialog): + dialog.table.selectRow(0) + opened = dialog._video() + assert opened is not None + wait_review(opened) + assert opened.video is not None + assert opened.image.pixmap() is not None + finally: + if opened is not None: + opened.close() + dialog.close()