feat(autocal): 添加标定记录总览与测点计划对照

集中显示采集覆盖、加载卸载响应与已确认法向灵敏度,并保存计划和复核快照。通过44项相关测试、已有视频会话检查及代码和文档检查。
This commit is contained in:
ykcheungab
2026-10-11 19:03:40 +08:00
parent 161e64278b
commit b200f3d55c
10 changed files with 663 additions and 11 deletions
@@ -0,0 +1,106 @@
"""Coverage and planned-point comparison over the existing review contract."""
import csv
import hashlib
import json
import math
from pathlib import Path
from .calibration_session import write_json
from .session_review import load_review, save_review
def plan_points(plan: dict, definition: dict) -> list[dict]:
points = {}
origin_x, origin_y = definition["origin_raster_xy_px"]
height, width = definition["raster_size_hw"]
orders = set()
for row in plan["rows"]:
if (row["mapping_sha256"] != definition["mapping_sha256"]
or row["u_positive"] != definition["u_positive"]
or row["v_positive"] != definition["v_positive"]
or (int(row["origin_raster_x_px"]), int(row["origin_raster_y_px"])) != (origin_x, origin_y)
or (int(row["raster_height_px"]), int(row["raster_width_px"])) != (height, width)):
raise ValueError("planned points use a different UV mapping, origin or axes")
u, v, order = int(row["u_px"]), int(row["v_px"]), int(row["order"])
position = [float(row[f"{axis}_sensor_mm"]) for axis in "xyz"]
if (not 0 <= origin_x + u < width or not 0 <= origin_y - v < height
or not all(math.isfinite(x) for x in position) or order <= 0 or order in orders):
raise ValueError("invalid planned point or duplicate order")
orders.add(order)
point = points.setdefault((u, v), {"uv_xy_px": [u, v], "position_sensor_mm": position, "orders": []})
if point["position_sensor_mm"] != position:
raise ValueError("one UV point has inconsistent planned 3D coordinates")
point["orders"].append(order)
if not points:
raise ValueError("the planned-point file contains no points")
return sorted(points.values(), key=lambda p: min(p["orders"]))
def read_plan(path: Path, definition: dict) -> dict:
with path.open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
plan = {"source": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "rows": rows}
plan_points(plan, definition)
return plan
def overview_rows(review: dict, plan: dict | None = None) -> list[dict]:
planned = plan_points(plan, review["uv_definition"]) if plan is not None else []
planned_uv = {tuple(p["uv_xy_px"]) for p in planned}
actual = {tuple(site["uv_xy_px"]): site for site in review["sites"]}
rows = []
for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]:
site = actual.get(tuple(point["uv_xy_px"]))
if site is not None and point.get("orders") and site.get("position_sensor_mm"):
if not all(math.isclose(a, b, rel_tol=0, abs_tol=1e-6)
for a, b in zip(point["position_sensor_mm"], site["position_sensor_mm"])):
raise ValueError("planned and recorded 3D coordinates do not match")
trials = [t for t in review["trials"] if site is not None and t.get("site_id") == site["site_id"]]
complete = [t for t in trials if t["status"] == "complete"]
windows = site["windows"] if site else []
confirmed = [w for w in windows if w["stability_verified"] and w["status"] == "candidate"]
all_reviewed = bool(complete) and all(
sum(w["trial_id"] == t["trial_id"] for w in confirmed)
== len(t.get("plan", {}).get("steps", [w for w in windows if w["trial_id"] == t["trial_id"]]))
and any(w["trial_id"] == t["trial_id"] for w in confirmed) for t in complete)
state = ("uncollected" if not trials else "incomplete" if len(complete) != len(trials)
else "reviewed" if all_reviewed else "pending")
fit = (site["confirmed_fit"] or site["fit"]) if site else None
training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate"
and (not site["confirmed_fit"] or w["stability_verified"])] if site else []
rows.append({
"site_id": site["site_id"] if site else None, "uv_xy_px": point["uv_xy_px"],
"position_sensor_mm": site.get("position_sensor_mm") if site else point["position_sensor_mm"],
"orders": point.get("orders", []), "planned": bool(point.get("orders")),
"status": state, "trials": len(trials), "complete_trials": len(complete),
"confirmed_windows": len(confirmed), "windows": len(windows),
"confirmed_levels_n": sorted({w["reference_n"] for w in confirmed if w["branch"] in {"zero", "load"}}),
"fit": fit, "fit_basis": ("confirmed" if site["confirmed_fit"] else "candidate") if fit else "none",
"fit_trials": len({w["trial_id"] for w in training}) if fit else 0,
"fit_range_n": [min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)] if fit else None,
})
return rows
def save_overview(review: dict, plan: dict | None) -> Path:
rows = overview_rows(review, plan)
path = save_review(review)
write_json(path.parent / "overview.json", {
"format_version": "autocal-overview-1.0", "session_id": review["session_id"],
"plan": plan, "sites": rows,
})
return path
def load_overview(directory: Path, path: Path) -> tuple[dict, dict | None]:
review = load_review(directory, path)
overview_path = path.parent / "overview.json"
plan = None
if overview_path.is_file():
saved = json.loads(overview_path.read_text(encoding="utf-8"))
if saved["format_version"] != "autocal-overview-1.0" or saved["session_id"] != review["session_id"]:
raise ValueError("overview identity mismatch")
plan = saved["plan"]
overview_rows(review, plan)
return review, plan
@@ -223,6 +223,7 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"source_directory": str(root), "session_status": manifest["status"],
"status": "exploratory", "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"],
}
@@ -351,7 +351,7 @@ class CalibrationPanel(QWidget):
self._sync()
def _review(self) -> None:
from .session_review_dialog import SessionReviewDialog
from .record_overview_dialog import RecordOverviewDialog
session = self.trial.session
directory = QFileDialog.getExistingDirectory(
@@ -360,7 +360,7 @@ class CalibrationPanel(QWidget):
)
if directory:
try:
dialog = SessionReviewDialog(Path(directory), self)
dialog = RecordOverviewDialog(Path(directory), self)
dialog.exec()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
@@ -0,0 +1,278 @@
"""Recorded-site coverage and normal response, with optional operator CSV plan."""
from pathlib import Path
from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout,
)
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
from ..session_review import review_session
from .session_review_dialog import SessionReviewDialog
class RecordOverviewDialog(QDialog):
def __init__(self, directory: Path, parent=None):
super().__init__(parent)
self.directory = directory
self.review = review_session(directory)
self.plan = None
self.rows = []
self.setWindowTitle(t("overview.title"))
self.resize(1240, 850)
layout = QVBoxLayout(self)
self.notice = QLabel(t("overview.notice"), self)
self.notice.setWordWrap(True)
layout.addWidget(self.notice)
toolbar = QHBoxLayout()
self.map_mode = QComboBox(self)
self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")])
toolbar.addWidget(self.map_mode)
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan),
("load", self._load), ("save", self._save)):
button = QPushButton(t("overview." + name), self)
button.setObjectName("overview_" + name)
button.clicked.connect(slot)
toolbar.addWidget(button)
layout.addLayout(toolbar)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
charts = QHBoxLayout()
self.uv_chart, self.response_chart = QChart(), QChart()
self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart)
for view in (self.uv_view, self.response_view):
view.setRenderHint(QPainter.RenderHint.Antialiasing)
charts.addWidget(self.uv_view, 1)
charts.addWidget(self.response_view, 2)
layout.addLayout(charts, 2)
self.table = QTableWidget(0, 11, self)
self.table.setObjectName("overviewSites")
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.table.setHorizontalHeaderLabels([t("overview.col_" + key) for key in (
"order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table, 1)
selection = QHBoxLayout()
self.trials = QComboBox(self)
self.trials.setObjectName("overviewTrial")
self.details_button = QPushButton(t("overview.details"), self)
selection.addWidget(QLabel(t("overview.trial_filter"), self))
selection.addWidget(self.trials, 1)
selection.addWidget(self.details_button)
layout.addLayout(selection)
self.selected_summary = QLabel(self)
self.selected_summary.setWordWrap(True)
layout.addWidget(self.selected_summary)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
self.table.itemSelectionChanged.connect(self._select_site)
self.map_mode.currentIndexChanged.connect(self._show_uv)
self.trials.currentIndexChanged.connect(self._show_response)
self.details_button.clicked.connect(self._details)
self._refresh()
def _refresh(self):
selected = self.table.currentRow()
self.rows = overview_rows(self.review, self.plan)
self.table.blockSignals(True)
self.table.setRowCount(len(self.rows))
for index, row in enumerate(self.rows):
fit = row["fit"]
sensitivity = (f"{fit['sensitivity_raw_per_n']:.6g} · " + t("overview." + row["fit_basis"])) if fit else "—"
values = [", ".join(map(str, row["orders"])) or "—", row["site_id"] or "—",
", ".join(map(str, row["uv_xy_px"])),
", ".join(f"{x:.3f}" for x in row["position_sensor_mm"]) if row["position_sensor_mm"] else "—",
t("overview." + row["status"]), f"{row['complete_trials']}/{row['trials']}",
f"{row['confirmed_windows']}/{row['windows']}",
", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity,
"–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])]
for column, value in enumerate(values):
self.table.setItem(index, column, QTableWidgetItem(value))
self.table.blockSignals(False)
collected = sum(row["trials"] > 0 for row in self.rows)
self.summary.setText(t("overview.summary").format(
collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—",
missing=sum(r["status"] == "uncollected" for r in self.rows),
incomplete=sum(r["status"] == "incomplete" for r in self.rows),
pending=sum(r["status"] == "pending" for r in self.rows),
reviewed=sum(r["status"] == "reviewed" for r in self.rows),
extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—",
) + ("\n" + t("overview.no_plan") if self.plan is None else ""))
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
if self.rows:
self.table.selectRow(min(max(selected, 0), len(self.rows) - 1))
self._select_site()
def _select_site(self):
index = self.table.currentRow()
self.trials.blockSignals(True)
self.trials.clear()
self.trials.addItem(t("overview.all_trials"), None)
if index >= 0:
site_id = self.rows[index]["site_id"]
for trial in self.review["trials"]:
if site_id is not None and trial.get("site_id") == site_id:
self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"])
self.trials.blockSignals(False)
self._show_uv()
self._show_response()
@staticmethod
def _clear_chart(chart):
chart.removeAllSeries()
for axis in chart.axes():
axis.hide()
chart.removeAxis(axis)
axis.deleteLater()
@staticmethod
def _axes(chart):
points = [point for series in chart.series() for point in series.points()]
for horizontal in (True, False):
axis = QValueAxis()
values = [p.x() if horizontal else p.y() for p in points]
if values:
low, high = min(values), max(values)
margin = max((high - low) * .1, .1)
axis.setRange(low - margin, high + margin)
chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
for series in chart.series():
series.attachAxis(axis)
def _show_uv(self):
self._clear_chart(self.uv_chart)
sensitivity = self.map_mode.currentIndex() == 1
values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]]
low, high = (min(values), max(values)) if values else (0, 0)
colors = {"uncollected": "#929aa7", "incomplete": "#ca4c46", "pending": "#d8911d", "reviewed": "#29835f"}
for index, row in enumerate(self.rows):
color = QColor(colors[row["status"]])
if sensitivity:
color = QColor("#929aa7")
if row["fit_basis"] == "confirmed":
value = row["fit"]["sensitivity_raw_per_n"]
ratio = (value - low) / (high - low) if high > low else .5
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
series = QScatterSeries()
series.setColor(color)
series.setBorderColor(QColor("#151515") if index == self.table.currentRow() else color)
series.setMarkerSize(15 if index == self.table.currentRow() else 10)
series.append(*row["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.table.selectRow(target))
self.uv_chart.addSeries(series)
self._axes(self.uv_chart)
definition = self.review["uv_definition"]
origin = definition.get("origin_raster_xy_px")
shape = definition.get("raster_size_hw")
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)")
if origin and shape:
axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5)
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)")
if origin and shape:
axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5)
self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high)
if sensitivity else t("overview.coverage_map"))
def _show_response(self):
self._clear_chart(self.response_chart)
index = self.table.currentRow()
site_id = self.rows[index]["site_id"] if index >= 0 else None
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
trial_id = self.trials.currentData()
windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else []
self.details_button.setEnabled(site is not None and bool(windows))
for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})):
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
for confirmed in (True, False):
points = [w for w in windows if w["trial_id"] == trial and w["branch"] == branch
and w["status"] == "candidate" and w["stability_verified"] == confirmed]
if not points:
continue
series = QScatterSeries()
series.setName(trial + " · " + t("calibration." + branch) + " · " + t("overview.confirmed" if confirmed else "overview.candidate"))
series.setMarkerShape(QScatterSeries.MarkerShape.MarkerShapeCircle if confirmed else QScatterSeries.MarkerShape.MarkerShapeRectangle)
series.setColor(QColor(color).lighter(100 + 20 * (trial_index % 4)))
series.setMarkerSize(10 if confirmed else 7)
for window in points:
series.append(window["reference_n"], window["raw_mean"])
self.response_chart.addSeries(series)
fit = (site["confirmed_fit"] or site["fit"]) if site else None
if fit and windows:
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
and w["status"] == "candidate" and (not site["confirmed_fit"] or w["stability_verified"])]
line = QLineSeries()
line.setName(t("overview.site_fit"))
for load in (min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)):
line.append(load, (load - fit["offset_n"]) / fit["gain_n_per_raw"])
self.response_chart.addSeries(line)
self._axes(self.response_chart)
for axis in self.response_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText(t("review.force_axis"))
for axis in self.response_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.raw_axis"))
self.response_chart.setTitle(t("overview.response_title"))
self.response_chart.legend().setAlignment(Qt.AlignmentFlag.AlignRight)
self.selected_summary.setText(t("overview.selected_fit").format(
sensitivity=f"{fit['sensitivity_raw_per_n']:.6g}" if fit else "—",
basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—",
) if windows else t("overview.no_windows"))
def _import_plan(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)")
if path:
try:
plan = read_plan(Path(path), self.review["uv_definition"])
overview_rows(self.review, plan)
self.plan = plan
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
def _clear_plan(self):
self.plan = None
self._refresh()
def _details(self):
row = self.rows[self.table.currentRow()]
index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"])
dialog = SessionReviewDialog(self.directory, self, review=self.review)
dialog.sites.setCurrentIndex(index)
trial = self.trials.currentData()
if trial:
windows = dialog.review["sites"][index]["windows"]
match = next((i for i, w in enumerate(windows) if w["trial_id"] == trial), None)
if match is not None:
dialog.table.selectRow(match)
dialog.exec()
self.review = dialog.review
self._refresh()
def _save(self):
try:
path = save_overview(self.review, self.plan)
self.message.setText(t("review.saved").format(path=path))
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
if path:
try:
review, plan = load_overview(self.directory, Path(path))
self.review, self.plan = review, plan
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
@@ -19,11 +19,11 @@ from ..session_review import review_session, save_review, load_review
class SessionReviewDialog(QDialog):
"""Open persisted data independently of the acquisition page's state."""
def __init__(self, directory: Path, parent=None) -> None:
def __init__(self, directory: Path, parent=None, *, review: dict | None = None) -> None:
super().__init__(parent)
self.setWindowTitle(t("review.title"))
self.resize(1080, 760)
self.review = review_session(directory)
self.review = review if review is not None else review_session(directory)
self.directory = directory
layout = QVBoxLayout(self)
self.notice = QLabel(t("review.notice"), self)