修复 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 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。