修复 Autocal 录像故障处理、复核回放与汇总统计
This commit is contained in:
@@ -104,6 +104,8 @@ finger_F001_<时间>_<标识>/
|
|||||||
`summary_export` 写 `derived/summary_*/` 与 `summary-latest.json`,按 `hold_id`(`reference.csv`
|
`summary_export` 写 `derived/summary_*/` 与 `summary-latest.json`,按 `hold_id`(`reference.csv`
|
||||||
数据行序号)区分重测,不自动标记稳定,不把 raw 与 N 混用。
|
数据行序号)区分重测,不自动标记稳定,不把 raw 与 N 混用。
|
||||||
|
|
||||||
|
见[录像工作流](recording-workflow.zh.md)。
|
||||||
|
|
||||||
### 多点会话格式
|
### 多点会话格式
|
||||||
|
|
||||||
多点标定按测点组织(`sites/<site_id>/repeat-00N/`),复核输入见[多点标定会话格式](multipoint-data-format.zh.md)。
|
多点标定按测点组织(`sites/<site_id>/repeat-00N/`),复核输入见[多点标定会话格式](multipoint-data-format.zh.md)。
|
||||||
|
|||||||
@@ -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 物理同步。"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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`。导出保持双精度,不沿用旧表六位有效数字作为计算输入。
|
`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 拼端点。
|
R² 为 `1-SSE/SST`;常量信号 SST=0 时为空并标记 flat_signal,零斜率不反推增益。endpoint 每 trial 用最高加载与初始零载的差除以参考力差;initial_step 为最低正加载均值减初始零载。跨 trial 只汇总这些量的均值及数量,缺配对或分母为零为空;不跨 trial 拼端点。
|
||||||
|
|
||||||
|
|||||||
@@ -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 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。
|
||||||
@@ -1298,6 +1298,10 @@ class Campaign:
|
|||||||
# called complete evidence.
|
# called complete evidence.
|
||||||
self._source_stopped = True
|
self._source_stopped = True
|
||||||
self.stop("the image source stopped")
|
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":
|
if self.capture_state == "closing":
|
||||||
self._finalise_close()
|
self._finalise_close()
|
||||||
return {
|
return {
|
||||||
@@ -1329,6 +1333,9 @@ class Campaign:
|
|||||||
self.capture_state == "recording"
|
self.capture_state == "recording"
|
||||||
and self._guard is not None
|
and self._guard is not None
|
||||||
and not self._guard.failed
|
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:
|
def capture_summary(self) -> dict:
|
||||||
|
|||||||
@@ -502,18 +502,19 @@ def build_sensitivity(windows: list[dict]) -> list[dict]:
|
|||||||
|
|
||||||
def aggregate_sensitivity(rows: 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."""
|
"""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:
|
for row in rows:
|
||||||
if row["status"] != "available" or row["slope_raw_per_N"] is None:
|
if row["status"] != "available" or row["slope_raw_per_N"] is None:
|
||||||
continue
|
continue
|
||||||
groups.setdefault((row["site_id"], row["component"]), []).append(row)
|
groups.setdefault((row["site_id"], row["component"], row["review_status"]), []).append(row)
|
||||||
result = []
|
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]
|
slopes = [float(row["slope_raw_per_N"]) for row in group]
|
||||||
result.append({
|
result.append({
|
||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"component": component,
|
"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_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,
|
"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),
|
"window_count": sum(int(row["window_count"] or 0) for row in group),
|
||||||
|
|||||||
@@ -551,6 +551,7 @@ class RecordOverviewDialog(QDialog):
|
|||||||
|
|
||||||
def _video(self):
|
def _video(self):
|
||||||
"""Open the pictures of the selected point's current window."""
|
"""Open the pictures of the selected point's current window."""
|
||||||
|
from ..video_review import trial_is_closed
|
||||||
from .video_review_dialog import VideoReviewDialog
|
from .video_review_dialog import VideoReviewDialog
|
||||||
|
|
||||||
index = self.table.currentRow()
|
index = self.table.currentRow()
|
||||||
@@ -571,8 +572,7 @@ class RecordOverviewDialog(QDialog):
|
|||||||
(item for item in self.review.get("trials") or []
|
(item for item in self.review.get("trials") or []
|
||||||
if item["trial_id"] == window["trial_id"]), None,
|
if item["trial_id"] == window["trial_id"]), None,
|
||||||
)
|
)
|
||||||
if trial is None or trial.get("status") != "complete":
|
if not trial_is_closed(self.directory, trial):
|
||||||
# An unclosed trial still has the writer's file open.
|
|
||||||
self.notice.setText(t("overview.guide_incomplete"))
|
self.notice.setText(t("overview.guide_incomplete"))
|
||||||
return None
|
return None
|
||||||
dialog = VideoReviewDialog(
|
dialog = VideoReviewDialog(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..session_review import review_session, save_review, load_review, reselect_review
|
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 .review_job import ReviewJob
|
||||||
from .chart_helpers import add_value_axes, clear_chart
|
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 []
|
(item for item in self.review.get("trials") or []
|
||||||
if item["trial_id"] == window["trial_id"]), None,
|
if item["trial_id"] == window["trial_id"]), None,
|
||||||
)
|
)
|
||||||
if trial is None or trial.get("status") != "complete":
|
if not trial_is_closed(self.directory, trial):
|
||||||
# Only a closed trial has a finished container to decode.
|
|
||||||
self.message.setText(t("overview.guide_incomplete"))
|
self.message.setText(t("overview.guide_incomplete"))
|
||||||
return None
|
return None
|
||||||
from .video_review_dialog import VideoReviewDialog
|
from .video_review_dialog import VideoReviewDialog
|
||||||
|
|||||||
@@ -293,13 +293,18 @@ class VideoReviewDialog(QDialog):
|
|||||||
window = self.window
|
window = self.window
|
||||||
if not window:
|
if not window:
|
||||||
return
|
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 = QScatterSeries()
|
||||||
trace.setMarkerSize(3)
|
trace.setMarkerSize(3)
|
||||||
trace.setName(t("review.valid_samples"))
|
trace.setName(t("review.valid_samples"))
|
||||||
trace.replace([
|
trace.replace([
|
||||||
QPointF((stamp - origin) / 1e9, value)
|
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.addSeries(trace)
|
||||||
self.curve_line = QLineSeries()
|
self.curve_line = QLineSeries()
|
||||||
@@ -308,15 +313,15 @@ class VideoReviewDialog(QDialog):
|
|||||||
reference = QLineSeries()
|
reference = QLineSeries()
|
||||||
reference.setName(t("video.reference"))
|
reference.setName(t("video.reference"))
|
||||||
reference.append(0.0, float(window.get("reference_n") or 0.0))
|
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))
|
float(window.get("reference_n") or 0.0))
|
||||||
self.curve.addSeries(reference)
|
self.curve.addSeries(reference)
|
||||||
axis_x = QValueAxis()
|
axis_x = QValueAxis()
|
||||||
axis_x.setTitleText(t("review.hold_time_axis"))
|
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 = QValueAxis()
|
||||||
axis_y.setTitleText(t("review.raw_axis"))
|
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))
|
values.append(float(window.get("reference_n") or 0.0))
|
||||||
low, high = (min(values), max(values)) if values else (0.0, 1.0)
|
low, high = (min(values), max(values)) if values else (0.0, 1.0)
|
||||||
margin = max((high - low) * 0.1, 0.1)
|
margin = max((high - low) * 0.1, 0.1)
|
||||||
@@ -335,7 +340,7 @@ class VideoReviewDialog(QDialog):
|
|||||||
return
|
return
|
||||||
window = self.window
|
window = self.window
|
||||||
frame = self.video.frames[index]
|
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
|
x = (frame.t_host_mono_ns - origin) / 1e9
|
||||||
axis = self.curve.axes(Qt.Orientation.Vertical)
|
axis = self.curve.axes(Qt.Orientation.Vertical)
|
||||||
low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0)
|
low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ segment stays a reason. The decoder is released by :meth:`WindowVideo.close`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -79,6 +80,24 @@ def _trial(review: dict, trial_id: str) -> dict | None:
|
|||||||
return 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(
|
def in_window_frames(
|
||||||
review: dict, *, trial_id: str, step_index: int
|
review: dict, *, trial_id: str, step_index: int
|
||||||
) -> list[FrameRow] | str:
|
) -> list[FrameRow] | str:
|
||||||
@@ -97,8 +116,8 @@ def in_window_frames(
|
|||||||
)
|
)
|
||||||
if directory is None:
|
if directory is None:
|
||||||
return NO_FRAME_SOURCE
|
return NO_FRAME_SOURCE
|
||||||
start = int(window["hold_start_host_mono_ns"])
|
start = int(window.get("t_start_host_mono_ns", window["hold_start_host_mono_ns"]))
|
||||||
end = int(window["hold_end_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))
|
unique, _duplicated = unique_frames(read_frame_index(directory))
|
||||||
frames = [
|
frames = [
|
||||||
frame for frame in unique.values()
|
frame for frame in unique.values()
|
||||||
@@ -205,7 +224,6 @@ class WindowVideo:
|
|||||||
frames: list[FrameRow]
|
frames: list[FrameRow]
|
||||||
#: Index of the frame currently decoded, or -1 when the decoder was reset.
|
#: Index of the frame currently decoded, or -1 when the decoder was reset.
|
||||||
position: int = -1
|
position: int = -1
|
||||||
_images: dict[int, np.ndarray] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frame_count(self) -> int:
|
def frame_count(self) -> int:
|
||||||
@@ -214,8 +232,6 @@ class WindowVideo:
|
|||||||
def frame(self, index: int) -> np.ndarray | None:
|
def frame(self, index: int) -> np.ndarray | None:
|
||||||
"""Image for ``frames[index]``, or ``None`` when that frame has no picture."""
|
"""Image for ``frames[index]``, or ``None`` when that frame has no picture."""
|
||||||
index = max(0, min(int(index), self.frame_count - 1))
|
index = max(0, min(int(index), self.frame_count - 1))
|
||||||
if index in self._images:
|
|
||||||
return self._images[index]
|
|
||||||
row = self.frames[index]
|
row = self.frames[index]
|
||||||
try:
|
try:
|
||||||
image, served = self.reader.video_frame_at(row.t_rel_s)
|
image, served = self.reader.video_frame_at(row.t_rel_s)
|
||||||
@@ -231,12 +247,10 @@ class WindowVideo:
|
|||||||
self.position = -1
|
self.position = -1
|
||||||
return None
|
return None
|
||||||
self.position = index
|
self.position = index
|
||||||
self._images[index] = image
|
|
||||||
return image
|
return image
|
||||||
|
|
||||||
def release(self) -> None:
|
def release(self) -> None:
|
||||||
self.reader.close()
|
self.reader.close()
|
||||||
self._images.clear()
|
|
||||||
self.position = -1
|
self.position = -1
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
|||||||
@@ -193,6 +193,40 @@ def test_a_stopped_source_keeps_the_repeat_incomplete(tmp_path):
|
|||||||
assert session["status"] == "incomplete"
|
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):
|
def test_a_simulated_repeat_is_steps_complete_but_not_recorded_evidence(tmp_path):
|
||||||
campaign = _campaign(repeats=1)
|
campaign = _campaign(repeats=1)
|
||||||
root = campaign.start(
|
root = campaign.start(
|
||||||
|
|||||||
@@ -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["candidate"]["slope_raw_per_N"] == pytest.approx(40.0)
|
||||||
assert by_review["confirmed"]["window_count"] == 2
|
assert by_review["confirmed"]["window_count"] == 2
|
||||||
assert by_review["confirmed"]["slope_raw_per_N"] == pytest.approx(40.0)
|
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)
|
||||||
|
|||||||
@@ -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)
|
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):
|
def test_decode_failure_is_reported_rather_than_showing_a_neighbour(tmp_path):
|
||||||
review = _review(tmp_path)
|
review = _review(tmp_path)
|
||||||
root = tmp_path / "rec"
|
root = tmp_path / "rec"
|
||||||
|
|||||||
@@ -147,3 +147,33 @@ def test_an_unclosed_trial_does_not_offer_the_video(qt_app, tmp_path):
|
|||||||
assert dialog.message.text()
|
assert dialog.message.text()
|
||||||
finally:
|
finally:
|
||||||
dialog.close()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user