补齐 Autocal 实时反馈与稳定窗口复核

显示实时力值、曲线及 UV 图层,并保存人工确认窗口和零载检查基准。定向测试、真实视频预览与已录会话复核闭环验证通过。
This commit is contained in:
ykcheungab
2026-10-11 18:08:09 +08:00
parent 04adf17f60
commit 34a1d9b1f1
12 changed files with 731 additions and 39 deletions
+128 -21
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import csv
import json
import math
import hashlib
from importlib.metadata import PackageNotFoundError, version
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
@@ -19,7 +21,24 @@ def _rows(path: Path) -> list[dict]:
return list(csv.DictReader(handle))
def review_session(directory: Path) -> dict:
def _fit(windows: list[dict]) -> dict | None:
raw = np.array([w["raw_mean"] for w in windows], dtype=float)
loads = np.array([w["reference_n"] for w in windows], dtype=float)
if (len(set(loads)) < 2 or raw.size < 2
or np.allclose(raw, raw[0], rtol=1e-9, atol=1e-12)):
return None
gain, offset = np.polyfit(raw, loads, 1)
if not math.isfinite(gain) or not math.isfinite(offset) or abs(gain) <= 1e-12:
return None
return {
"gain_n_per_raw": float(gain), "offset_n": float(offset),
"sensitivity_raw_per_n": float(1 / gain),
"rmse_n": float(np.sqrt(np.mean((gain * raw + offset - loads) ** 2))),
"windows": len(windows), "status": "exploratory",
}
def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"""Join frame identity to host timestamps and inspect only complete windows.
Manual reference values are declarations. Fits remain exploratory regardless
@@ -31,6 +50,10 @@ def review_session(directory: Path) -> dict:
raise ValueError("select a calibration session root, not an individual trial")
windows = []
issues = []
selections = selections or {}
used = set()
inputs = {"session.json": hashlib.sha256((root / "session.json").read_bytes()).hexdigest()}
analysis_inputs = {}
for trial in manifest["trials"]:
trial_id = trial["trial_id"]
if trial["status"] != "complete":
@@ -40,6 +63,13 @@ def review_session(directory: Path) -> dict:
if not path.is_relative_to(root):
raise ValueError("trial directory is outside the calibration session")
metadata = json.loads((path / "session.json").read_text(encoding="utf-8"))
analysis_inputs[trial_id] = {
"config_snapshot": metadata.get("config_snapshot"),
"config_sha256": metadata.get("config_sha256"),
"uv_definition": metadata.get("uv_definition"),
}
for name in ("session.json", "frames.csv", "forces.csv", "reference.csv"):
inputs[str((path / name).relative_to(root))] = hashlib.sha256((path / name).read_bytes()).hexdigest()
if (metadata.get("trial_status") != "complete"
or metadata.get("trial_id") != trial_id
or metadata.get("site_id") != trial["site_id"]
@@ -63,7 +93,8 @@ def review_session(directory: Path) -> dict:
stamp = frames.get(int(row["frame_id"]))
raw = float(row["FN_raw"])
if stamp is not None and math.isfinite(raw):
samples.append((stamp, raw))
samples.append((stamp, int(row["frame_id"]), raw))
samples.sort()
for reference in _rows(path / "reference.csv"):
if reference["hold_complete"] != "1":
continue
@@ -72,19 +103,37 @@ def review_session(directory: Path) -> dict:
load = float(reference["F_ref_normal_N"])
if end <= start or not math.isfinite(load) or not 0 <= load <= 30:
raise ValueError(f"invalid reference window in {trial_id}")
raw = np.array([v for stamp, v in samples if start <= stamp < end], dtype=float)
key = f"{trial_id}:{int(reference['step_index'])}"
choice = selections.get(key)
selected_start, selected_end = start, end
verified = False
if choice is not None:
used.add(key)
selected_start = int(choice["t_start_host_mono_ns"])
selected_end = int(choice["t_end_host_mono_ns"])
verified = choice["stability_verified"] is True
if not start <= selected_start < selected_end <= end:
raise ValueError(f"selection outside recorded hold: {key}")
trace = [(stamp, frame, value) for stamp, frame, value in samples if start <= stamp < end]
raw = np.array([v for stamp, _, v in trace if selected_start <= stamp < selected_end], dtype=float)
if verified and raw.size < 2:
raise ValueError(f"not enough valid samples to confirm: {key}")
windows.append({
"trial_id": trial_id, "site_id": trial["site_id"],
"step_index": int(reference["step_index"]),
"branch": reference["branch"], "reference_n": load,
"t_start_host_mono_ns": start, "t_end_host_mono_ns": end,
"duration_s": (end - start) / 1e9, "samples": int(raw.size),
"hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end,
"t_start_host_mono_ns": selected_start, "t_end_host_mono_ns": selected_end,
"duration_s": (selected_end - selected_start) / 1e9, "samples": int(raw.size),
"trace": trace,
"raw_mean": float(raw.mean()) if raw.size else None,
"raw_std": float(raw.std()) if raw.size else None,
"stability_verified": reference["stability_verified"] == "1",
"stability_verified": verified,
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"status": "candidate" if raw.size >= 2 else "insufficient_samples",
})
if set(selections) != used:
raise ValueError("selection refers to an unavailable hold window")
sites = []
for site in manifest["sites"]:
selected = [w for w in windows if w["site_id"] == site["site_id"]]
@@ -92,33 +141,91 @@ def review_session(directory: Path) -> dict:
# merges repeated loads and would hide the hysteresis under inspection.
training = [w for w in selected if w["branch"] in {"zero", "load"}
and w["status"] == "candidate"]
raw = np.array([w["raw_mean"] for w in training], dtype=float)
loads = np.array([w["reference_n"] for w in training], dtype=float)
fit = None
if (len(set(loads)) >= 2 and raw.size >= 2
and not np.allclose(raw, raw[0], rtol=1e-9, atol=1e-12)):
gain, offset = np.polyfit(raw, loads, 1)
if math.isfinite(gain) and math.isfinite(offset) and abs(gain) > 1e-12:
fit = {
"gain_n_per_raw": float(gain), "offset_n": float(offset),
"sensitivity_raw_per_n": float(1 / gain),
"rmse_n": float(np.sqrt(np.mean((gain * raw + offset - loads) ** 2))),
"windows": len(training), "status": "exploratory",
}
sites.append({**site, "windows": selected, "fit": fit})
fit = _fit(training)
confirmed = [w for w in training if w["stability_verified"]]
confirmed_fit = _fit(confirmed)
sites.append({**site, "windows": selected, "fit": fit, "confirmed_fit": confirmed_fit})
return {
"format_version": "autocal-review-1.0", "session_id": manifest["session_id"],
"source_directory": str(root), "session_status": manifest["status"],
"status": "exploratory", "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
}
def load_review(directory: Path, review_path: Path) -> dict:
"""Recompute saved selections only when the acquired inputs still match."""
saved = json.loads(review_path.read_text(encoding="utf-8"))
result = review_session(directory, selections=saved["selections"])
if saved["session_id"] != result["session_id"] or saved["input_sha256"] != result["input_sha256"]:
raise ValueError("saved review does not match the recorded inputs")
return result
def save_review(result: dict) -> Path:
"""Save a new derived artifact; never replace acquired measurements."""
root = Path(result["source_directory"])
fresh = review_session(root, selections=result["selections"])
if fresh["input_sha256"] != result["input_sha256"]:
raise ValueError("recorded inputs changed since this review was opened")
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
directory = root / "derived" / f"review_{stamp}_{uuid4().hex[:8]}"
directory.mkdir(parents=True, exist_ok=False)
path = directory / "review.json"
write_json(path, result)
saved = dict(fresh)
versions = {}
for package in ("orisys", "orisys-autocal", "orisys-app-shared", "numpy"):
try:
versions[package] = version(package)
except PackageNotFoundError:
versions[package] = "unavailable"
saved["analysis_versions"] = versions
saved["baselines"] = _save_baselines(fresh, directory)
write_json(path, saved)
return path
def _save_baselines(result: dict, destination: Path) -> list[dict]:
"""Snapshot a reviewed zero-load image for inspection, not tracker state."""
import cv2
root = Path(result["source_directory"])
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
trials = {t["trial_id"]: t for t in manifest["trials"]}
baselines = []
for site in result["sites"]:
# Each repeat has its own initial zero and its own image baseline.
for window in site["windows"]:
if window["branch"] != "zero" or not window["stability_verified"]:
continue
selected = [r for r in window["trace"] if window["t_start_host_mono_ns"] <= r[0] < window["t_end_host_mono_ns"]]
frame_id = selected[0][1]
trial_id = window["trial_id"]
trial_path = (root / trials[trial_id]["directory"]).resolve()
row = next(r for r in _rows(trial_path / "frames.csv") if int(r["frame_id"]) == frame_id)
item = {"site_id": site["site_id"], "trial_id": trial_id,
"frame_id": frame_id, "role": "reviewed_zero_load_inspection",
"tracker_reference": False, "status": "unavailable"}
if row.get("in_video") == "1" and row.get("segment") and row.get("segment_frame"):
media = (trial_path / f"raw_{int(row['segment']):04d}.mp4")
capture = cv2.VideoCapture(str(media))
try:
# Recorder indices are one-based; OpenCV seeks from zero.
position = int(row["segment_frame"]) - 1
capture.set(cv2.CAP_PROP_POS_FRAMES, position)
ok, image = capture.read()
ok = ok and round(capture.get(cv2.CAP_PROP_POS_FRAMES)) == position + 1
finally:
capture.release()
if ok:
name = f"baseline_{trial_id}.png"
ok, encoded = cv2.imencode(".png", image)
if not ok:
raise OSError("cannot encode baseline image")
(destination / name).write_bytes(encoded.tobytes())
item.update(status="saved", image=name,
source_video=str(media.relative_to(root)),
segment_frame=int(row["segment_frame"]),
image_sha256=hashlib.sha256(encoded.tobytes()).hexdigest())
baselines.append(item)
return baselines
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
QSpinBox,
QScrollArea,
QSizePolicy,
QTabWidget,
QVBoxLayout,
QWidget,
@@ -27,6 +29,7 @@ from PySide6.QtWidgets import (
from ..sensor.uv_flat import site_at, uv_origin
from ..trial import ManualTrial, load_path
from .sensor_panel import FlatSurfaceView
from .live_force_view import LiveForceView
class CalibrationPanel(QWidget):
@@ -148,7 +151,32 @@ class CalibrationPanel(QWidget):
self.result_label = QLabel(controls)
self.result_label.setWordWrap(True)
form.addRow(self.result_label)
body.addWidget(controls, 2)
for label in (self.path_label, self.target_label, self.result_label):
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
for widget in (self.target_label, self.reference_label, self.reference,
actions, self.progress_label, self.result_label):
form.removeWidget(widget)
right = QWidget(self)
right.setMinimumWidth(560)
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
control_scroll = QScrollArea(right)
control_scroll.setWidgetResizable(True)
control_scroll.setMinimumHeight(150)
control_scroll.setWidget(controls)
right_layout.addWidget(control_scroll, 3)
right_layout.addWidget(self.target_label)
reference_row = QHBoxLayout()
reference_row.addWidget(self.reference_label)
reference_row.addWidget(self.reference, 1)
right_layout.addLayout(reference_row)
right_layout.addWidget(actions)
right_layout.addWidget(self.progress_label)
right_layout.addWidget(self.result_label)
self.force_view = LiveForceView(source_panel.worker, right)
self.force_view.setMaximumHeight(280)
right_layout.addWidget(self.force_view, 2)
body.addWidget(right, 2)
outer.addLayout(body, 1)
self.output_browse.clicked.connect(self._choose_output)
@@ -339,6 +367,7 @@ class CalibrationPanel(QWidget):
def shutdown(self) -> None:
self._timer.stop()
self.force_view.shutdown()
try:
if self.trial.status == "recording":
self.trial.finish(aborted=True, reason="application closed")
@@ -423,6 +452,7 @@ class CalibrationPanel(QWidget):
)
def retranslate(self) -> None:
self.force_view.retranslate()
self.model_label.setText(t("calibration.sensor_model"))
self.sn_label.setText(t("calibration.sensor_sn"))
self.output_label.setText(t("calibration.output"))
@@ -6,12 +6,15 @@ from datetime import datetime
from pathlib import Path
import numpy as np
import cv2
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr, draw_arrow_display, flow_colormap
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
QFileDialog,
QCheckBox,
QComboBox,
QGridLayout,
QHBoxLayout,
@@ -46,6 +49,7 @@ class FingerSourcePanel(QWidget):
self.worker = FingerAcquisitionWorker(on_error=self.errorReported.emit)
self._last_frame_id: tuple[int, int] | None = None
self._last_image: QImage | None = None
self._last_packet = None
self._active = False
self._error = ""
self._status_key = "source.idle"
@@ -105,6 +109,22 @@ class FingerSourcePanel(QWidget):
self.preview_hint = QLabel(self)
self.preview_hint.setWordWrap(True)
layout.addWidget(self.preview_hint)
display_row = QHBoxLayout()
self.background_check = QCheckBox(self)
self.background_check.setObjectName("calibrationRealBackground")
self.background_check.setChecked(True)
self.arrows_check = QCheckBox(self)
self.arrows_check.setObjectName("calibrationArrows")
self.depth_check = QCheckBox(self)
self.depth_check.setObjectName("calibrationDepth")
for check in (self.background_check, self.arrows_check, self.depth_check):
display_row.addWidget(check)
check.toggled.connect(self._redraw)
display_row.addStretch()
layout.addLayout(display_row)
self.display_note = QLabel(self)
self.display_note.setWordWrap(True)
layout.addWidget(self.display_note)
self.preview = QLabel(self)
self.preview.setObjectName("fingerVideoPreview")
@@ -138,6 +158,11 @@ class FingerSourcePanel(QWidget):
self.start_button.setText(t("source.start"))
self.stop_button.setText(t("source.stop"))
self.preview_hint.setText(t("source.preview_only"))
self.background_check.setText(t("feedback.background"))
self.background_check.setToolTip(t("feedback.background_tip"))
self.arrows_check.setText(t("feedback.arrows"))
self.depth_check.setText(t("feedback.depth"))
self.depth_check.setToolTip(t("feedback.depth_tip"))
self._set_status(self._status_key)
if self._last_image is None:
self.preview.setText(t("source.no_frame"))
@@ -177,6 +202,7 @@ class FingerSourcePanel(QWidget):
self._error = ""
self._last_frame_id = None
self._last_image = None
self._last_packet = None
self.preview.clear()
self.preview.setText(t("source.no_frame"))
self.frame_label.clear()
@@ -229,7 +255,8 @@ class FingerSourcePanel(QWidget):
identity = (packet.session_id, packet.frame_id)
if identity != self._last_frame_id:
self._last_frame_id = identity
self._show_frame(packet.processed_frame)
self._last_packet = packet
self._redraw()
acquired = datetime.fromtimestamp(packet.timestamp).strftime(
"%H:%M:%S.%f"
)[:-3]
@@ -262,6 +289,54 @@ class FingerSourcePanel(QWidget):
}.get(reason, "source.stopped")
self._set_status(key)
def _redraw(self) -> None:
packet = self._last_packet
if packet is None:
return
frame = np.array(packet.processed_frame, copy=True)
mask = getattr(packet, "valid_mask", None)
if mask is not None and np.shape(mask) != frame.shape[:2]:
mask = None
if not self.background_check.isChecked():
frame[:] = (235, 230, 224)
if mask is not None:
frame[~np.asarray(mask, dtype=bool)] = (25, 25, 25)
notes = []
if self.depth_check.isChecked():
depth = getattr(packet, "depth", None)
fresh = (getattr(packet, "depth_valid", None) is True
and getattr(packet, "depth_frame_id", None) == packet.frame_id)
if fresh and depth is not None and np.shape(depth) == frame.shape[:2]:
# Fixed scale in SDK divergence units, never millimetres.
colored = depth_map_to_bgr(depth, flow_colormap("light"),
value_range=(0, 1), invalid_mask=mask)
if colored is not None:
valid = np.isfinite(depth)
if mask is not None:
valid &= np.asarray(mask, dtype=bool)
blended = cv2.addWeighted(frame, .4, colored, .6, 0)
frame[valid] = blended[valid]
notes.append(t("feedback.depth_scale"))
else:
notes.append(t("feedback.depth_missing"))
else:
notes.append(t("feedback.depth_missing"))
if self.arrows_check.isChecked():
flow = getattr(packet, "flow", None)
fresh = (getattr(packet, "flow_valid", None) is True
and getattr(packet, "flow_frame_id", None) == packet.frame_id)
if fresh and flow is not None:
frame = draw_arrow_display(frame, flow, background="captured",
draw_style="finger", valid_mask=mask,
colormap=flow_colormap("light"))
diagnostic = getattr(frame, "frame_diagnostic", None)
if diagnostic is not None:
notes.append(str(diagnostic))
else:
notes.append(t("feedback.flow_missing"))
self.display_note.setText(" · ".join(notes))
self._show_frame(frame)
def _show_frame(self, frame: np.ndarray) -> None:
if frame.ndim != 3 or frame.shape[2] != 3:
return
@@ -0,0 +1,112 @@
"""Operator force feedback from the same samples written by the recorder."""
from __future__ import annotations
import math
import threading
import time
from collections import deque
import pyqtgraph as pg
from orisys_app_shared.force.history import MetricHistory
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import to_record_sample
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
class LiveForceView(QWidget):
"""Collect on the worker thread; update widgets only on the GUI thread."""
def __init__(self, worker, parent=None):
super().__init__(parent)
self.worker = worker
self._lock = threading.Lock()
self._pending = deque(maxlen=5400)
self._overflow = 0
self._session = None
self._last_arrival = None
self._sample = None
self.history = MetricHistory(channels=3, window_sec=60, max_fps=90)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.readings = QLabel(self)
self.readings.setObjectName("calibrationForceValues")
self.readings.setWordWrap(True)
self.readings.setStyleSheet("font-size: 16px; font-weight: 600;")
self.state = QLabel(self)
self.state.setWordWrap(True)
layout.addWidget(self.readings)
layout.addWidget(self.state)
self.plot = pg.PlotWidget(background="w")
self.plot.setMinimumHeight(130)
self.plot.showGrid(x=True, y=True, alpha=.15)
self.plot.addLegend()
self.curves = [self.plot.plot(name=name, pen=pg.mkPen(color, width=2),
connect="finite")
for name, color in (("FN", "#167ac6"), ("FX", "#dc8413"),
("FY", "#40854b"))]
layout.addWidget(self.plot, 1)
worker.add_sink(self.on_packet)
self._timer = QTimer(self)
self._timer.setInterval(100)
self._timer.timeout.connect(self.refresh)
self._timer.start()
self.retranslate()
self.refresh()
def on_packet(self, packet):
sample = to_record_sample(packet)
with self._lock:
if len(self._pending) == self._pending.maxlen:
self._overflow += 1
self._pending.append((packet.session_id, sample, time.monotonic()))
def refresh(self):
with self._lock:
pending = list(self._pending)
self._pending.clear()
overflow = self._overflow
for session, sample, arrival in pending:
if session != self._session:
self.history.reset()
self._session = session
values = sample.raw_triplet() if sample.valid else (math.nan,) * 3
self.history.push(sample.mono_t, *values)
self._sample, self._last_arrival = sample, arrival
state = self.history.state() if self.history.sample_count else None
if state is not None:
for index, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, index])
self.plot.setXRange(state.x_range[0], max(10, float(state.times[-1])), padding=0)
sample = self._sample
if sample is None:
self.readings.setText(t("feedback.waiting"))
else:
values = []
for axis in ("fn", "fx", "fy"):
raw = sample.raw_for(axis) if sample.valid else math.nan
newton = sample.newton_for(axis) if sample.valid else math.nan
raw_text = f"{raw:.6g}" if math.isfinite(raw) else "—"
n_text = f"{newton:.3f} N" if math.isfinite(newton) else t("feedback.uncalibrated")
values.append(f"{axis.upper()}: {raw_text} raw · {n_text}")
self.readings.setText(" ".join(values))
if not self.worker.is_running:
message = t("feedback.stopped")
elif self._last_arrival is None or time.monotonic() - self._last_arrival > 1:
message = t("feedback.stale")
elif not sample.valid:
message = t("feedback.invalid").format(reason=sample.invalid_reason)
else:
message = t("feedback.raw_note")
if overflow:
message += " · " + t("feedback.overflow").format(count=overflow)
self.state.setText(message)
def retranslate(self):
self.plot.setLabel("left", t("feedback.raw_axis"))
self.plot.setLabel("bottom", t("feedback.time_axis"))
def shutdown(self):
self._timer.stop()
self.worker.remove_sink(self.on_packet)
@@ -6,14 +6,14 @@ from pathlib import Path
from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout,
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog,
)
from ..session_review import review_session, save_review
from ..session_review import review_session, save_review, load_review
class SessionReviewDialog(QDialog):
@@ -24,6 +24,7 @@ class SessionReviewDialog(QDialog):
self.setWindowTitle(t("review.title"))
self.resize(1080, 760)
self.review = review_session(directory)
self.directory = directory
layout = QVBoxLayout(self)
self.notice = QLabel(t("review.notice"), self)
self.notice.setWordWrap(True)
@@ -35,8 +36,10 @@ class SessionReviewDialog(QDialog):
u, v = site["uv_xy_px"]
self.sites.addItem(f"{site['site_id']} · U {u}, V {v} px")
self.save_button = QPushButton(t("review.save"), self)
self.load_button = QPushButton(t("review.load_selection"), self)
row.addWidget(self.sites, 1)
row.addWidget(self.save_button)
row.addWidget(self.load_button)
layout.addLayout(row)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
@@ -59,11 +62,35 @@ class SessionReviewDialog(QDialog):
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table, 1)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
editor = QHBoxLayout()
self.start_s = QDoubleSpinBox(self)
self.end_s = QDoubleSpinBox(self)
for spin in (self.start_s, self.end_s):
spin.setDecimals(3)
spin.setSuffix(" s")
self.verified = QCheckBox(t("review.confirm_stable"), self)
self.apply_button = QPushButton(t("review.apply_window"), self)
editor.addWidget(QLabel(t("review.start_offset"), self))
editor.addWidget(self.start_s)
editor.addWidget(QLabel(t("review.end_offset"), self))
editor.addWidget(self.end_s)
editor.addWidget(self.verified)
editor.addWidget(self.apply_button)
layout.addLayout(editor)
self.trace_chart = QChart()
self.trace_view = QChartView(self.trace_chart, self)
self.trace_view.setMinimumHeight(130)
layout.addWidget(self.trace_view, 1)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
self.sites.currentIndexChanged.connect(self._show_site)
self.save_button.clicked.connect(self._save)
self.load_button.clicked.connect(self._load)
self.table.itemSelectionChanged.connect(self._show_window)
self.apply_button.clicked.connect(self._apply_window)
self._show_site()
def _show_site(self) -> None:
@@ -81,17 +108,17 @@ class SessionReviewDialog(QDialog):
quality = t("review." + window["status"])
if not window["reference_alignment_possible"]:
quality += " · " + t("review.replay")
if not window["stability_verified"]:
quality += " · " + t("review.unverified")
quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified")
values = [window["trial_id"], t("calibration." + window["branch"]),
f"{window['reference_n']:g}", str(window["samples"]),
self._number(window["raw_mean"]), self._number(window["raw_std"]),
f"{window['duration_s']:g}", quality]
for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value))
fit = site["fit"]
fit = site["confirmed_fit"] or site["fit"]
self.summary.setText(
t("review.fit").format(**fit) if fit else t("review.no_fit")
((t("review.confirmed_fit") if site["confirmed_fit"] else t("review.candidate_fit"))
+ "\n" + t("review.fit").format(**fit)) if fit else t("review.no_fit")
)
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
series = QScatterSeries()
@@ -119,19 +146,101 @@ class SessionReviewDialog(QDialog):
self.chart.setTitle(t("review.chart_title"))
self._show_uv(index)
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
if windows:
self.table.selectRow(0)
self._show_window()
def _current_window(self):
site, row = self.sites.currentIndex(), self.table.currentRow()
if site < 0 or row < 0 or row >= len(self.review["sites"][site]["windows"]):
return None
return self.review["sites"][site]["windows"][row]
def _show_window(self):
window = self._current_window()
self.trace_chart.removeAllSeries()
for axis in self.trace_chart.axes():
self.trace_chart.removeAxis(axis)
self.apply_button.setEnabled(window is not None)
if window is None:
return
origin = window["hold_start_host_mono_ns"]
duration = (window["hold_end_host_mono_ns"] - origin) / 1e9
self.start_s.setRange(0, duration)
self.end_s.setRange(0, duration)
self.start_s.setValue((window["t_start_host_mono_ns"] - origin) / 1e9)
self.end_s.setValue((window["t_end_host_mono_ns"] - origin) / 1e9)
self.verified.setChecked(window["stability_verified"])
trace = QScatterSeries()
trace.setMarkerSize(3)
trace.setName(t("review.valid_samples"))
trace.replace([QPointF((stamp - origin) / 1e9, raw) for stamp, _, raw in window["trace"]])
self.trace_chart.addSeries(trace)
if window["trace"]:
raw_values = [r[2] for r in window["trace"]]
low, high = min(raw_values), max(raw_values)
margin = max((high - low) * .1, .1)
for stamp in (window["t_start_host_mono_ns"], window["t_end_host_mono_ns"]):
line = QLineSeries()
line.append((stamp - origin) / 1e9, low - margin)
line.append((stamp - origin) / 1e9, high + margin)
self.trace_chart.addSeries(line)
self.trace_chart.createDefaultAxes()
for axis in self.trace_chart.axes(Qt.Orientation.Horizontal):
axis.setRange(0, duration)
axis.setTitleText(t("review.hold_time_axis"))
for axis in self.trace_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.raw_axis"))
self.trace_chart.legend().hide()
self.trace_chart.setTitle(t("review.trace_title"))
def _apply_window(self):
window = self._current_window()
if window is None:
return
selections = dict(self.review["selections"])
origin = window["hold_start_host_mono_ns"]
selections[f"{window['trial_id']}:{window['step_index']}"] = {
"t_start_host_mono_ns": origin + round(self.start_s.value() * 1e9),
"t_end_host_mono_ns": origin + round(self.end_s.value() * 1e9),
"stability_verified": self.verified.isChecked(),
}
row = self.table.currentRow()
try:
updated = review_session(self.directory, selections=selections)
if updated["input_sha256"] != self.review["input_sha256"]:
raise ValueError("recorded inputs changed since this review was opened")
self.review = updated
self._show_site()
self.table.selectRow(row)
self._show_window()
self.message.setText(t("review.selection_unsaved"))
except (OSError, ValueError) as exc:
self.message.setText(str(exc))
def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"),
str(self.directory / "derived"), "JSON (*.json)")
if path:
try:
self.review = load_review(self.directory, Path(path))
self._show_site()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(str(exc))
def _show_uv(self, selected: int) -> None:
self.uv_chart.removeAllSeries()
for axis in self.uv_chart.axes():
self.uv_chart.removeAxis(axis)
values = [s["fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["fit"]]
fits = [s["confirmed_fit"] or s["fit"] for s in self.review["sites"]]
values = [fit["sensitivity_raw_per_n"] for fit in fits if fit]
low, high = (min(values), max(values)) if values else (0, 0)
for index, site in enumerate(self.review["sites"]):
series = QScatterSeries()
series.setName(site["site_id"])
color = QColor("#929aa7")
if site["fit"]:
value = site["fit"]["sensitivity_raw_per_n"]
if fits[index]:
value = fits[index]["sensitivity_raw_per_n"]
ratio = (value - low) / (high - low) if high > low else .5
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
series.setColor(color)
@@ -164,5 +273,5 @@ class SessionReviewDialog(QDialog):
try:
path = save_review(self.review)
self.message.setText(t("review.saved").format(path=path))
except OSError as exc:
except (OSError, ValueError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))