修复 Autocal 录像故障处理、复核回放与汇总统计

This commit is contained in:
ykcheungab
2026-10-17 12:30:15 +08:00
parent 19426c3c4a
commit 7607653c18
18 changed files with 15042 additions and 23 deletions
@@ -104,6 +104,8 @@ finger_F001_<时间>_<标识>/
`summary_export` 写 `derived/summary_*/` 与 `summary-latest.json`,按 `hold_id`(`reference.csv`
数据行序号)区分重测,不自动标记稳定,不把 raw 与 N 混用。
见[录像工作流](recording-workflow.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`。导出保持双精度,不沿用旧表六位有效数字作为计算输入。
方法固定为 `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 拼端点。
@@ -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.
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:
+5 -4
View File
@@ -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),
@@ -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(
@@ -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
@@ -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)
@@ -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:
@@ -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(
+16
View File
@@ -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)
+33
View File
@@ -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"
@@ -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()