完善 Autocal 多测点标定流程及会话复核

统一测点坐标、保持窗口和试验目录,补齐逐点初步分析。定向测试与真实视频闭环通过;两项轮询失败在修改前版本同样复现。
This commit is contained in:
ykcheungab
2026-10-11 17:43:16 +08:00
parent 7828bc1fe9
commit 04adf17f60
17 changed files with 1347 additions and 403 deletions
@@ -0,0 +1,93 @@
"""Autocal's multi-site manifest; the shared recorder owns each trial's files."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
def write_json(path: Path, payload: dict) -> None:
"""Replace a manifest atomically, keeping the previous version on failure."""
temporary = path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(path)
class CalibrationSession:
"""One sensor/configuration/mapping identity, with independent trial attempts."""
def __init__(self, root: Path, metadata: dict) -> None:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
identity = []
for key in ("sensor_model", "sensor_sn"):
value = re.sub(r"[^\w-]+", "-", metadata[key]).strip("-_")[:48]
if not value:
raise ValueError("sensor model and SN need letters or numbers")
identity.append(value)
self.directory = root / f"{'_'.join(identity)}_{stamp}_{uuid4().hex[:12]}"
self.identity = self.identity_of(metadata)
self.document = {
"format_version": "autocal-session-1.0",
"session_id": self.directory.name,
**self.identity,
"config_snapshot": metadata["config_snapshot"],
"started_at": datetime.now(timezone.utc).isoformat(),
"status": "in_progress",
"sites": [],
"trials": [],
}
self.directory.mkdir(parents=True, exist_ok=False)
self.save()
@staticmethod
def identity_of(metadata: dict) -> dict:
return {
"sensor_model": metadata["sensor_model"],
"sensor_sn": metadata["sensor_sn"],
"config_sha256": metadata["config_sha256"],
"uv_definition": metadata["uv_definition"],
}
def add_trial(self, metadata: dict) -> dict:
if self.document["status"] != "in_progress":
raise RuntimeError("calibration session is closed")
if self.identity_of(metadata) != self.identity:
raise ValueError("finish this session before changing sensor, config or UV mapping")
site = metadata["site"]
sites = self.document["sites"]
stored = next((item for item in sites if item["uv_xy_px"] == site["uv_xy_px"]), None)
if stored is None:
stored = {"site_id": f"site-{len(sites) + 1:03d}", **site}
sites.append(stored)
trials = self.document["trials"]
trial_id = f"trial-{len(trials) + 1:03d}"
entry = {
"trial_id": trial_id,
"site_id": stored["site_id"],
"repeat_index": 1 + sum(item["site_id"] == stored["site_id"] for item in trials),
"directory": f"trials/{trial_id}",
"status": "incomplete",
"reason": "trial has not finished",
"plan": metadata["plan"],
}
trials.append(entry)
self.save()
return entry
def save(self) -> None:
write_json(self.directory / "session.json", self.document)
def close(self) -> str:
trials = self.document["trials"]
self.document["status"] = (
"complete" if trials and all(t["status"] == "complete" for t in trials)
else "incomplete"
)
self.document["ended_at"] = datetime.now(timezone.utc).isoformat()
self.save()
return self.document["status"]
@@ -17,6 +17,7 @@ direction) and triangle structure rather than photorealism.
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
@@ -96,6 +97,63 @@ class FlatSurface:
return max(0, min(self.width - 1, x)), max(0, min(self.height - 1, y))
@dataclass(frozen=True)
class Site:
u_px: int
v_px: int
position_sensor_mm: tuple[float, float, float]
face_index: int
raster_xy_px: tuple[int, int]
uv_origin_raster_xy_px: tuple[int, int]
raster_size_hw: tuple[int, int]
mapping_sha256: str
def uv_origin(flat) -> tuple[int, int]:
"""Lowest valid pixel in the valid column nearest the image midpoint."""
rows, columns = np.nonzero(flat.face_index >= 0)
if not len(rows):
raise ValueError("UV map has no valid sensor pixels")
middle = (flat.width - 1) / 2
column = int(columns[np.argmin(np.abs(columns - middle))])
row = int(rows[columns == column].max())
return column, row
def site_at(
flat, u_px: int, v_px: int, *, origin: tuple[int, int] | None = None,
mapping_digest: str | None = None,
) -> Site:
"""Resolve a precise raster pixel; empty mask pixels cannot become sites."""
if flat is None:
raise ValueError("UV map is not loaded")
origin_x, origin_y = origin if origin is not None else uv_origin(flat)
raster_x, raster_y = origin_x + int(u_px), origin_y - int(v_px)
sample = flat.sample(raster_x, raster_y)
if sample is None:
raise ValueError("UV pixel is outside the valid sensor surface")
point, _normal, face = sample
return Site(
int(u_px),
int(v_px),
tuple(float(v) for v in point),
int(face),
(raster_x, raster_y),
(origin_x, origin_y),
(flat.height, flat.width),
mapping_digest if mapping_digest is not None else mapping_sha256(flat),
)
def mapping_sha256(flat) -> str:
"""Identify the actual pixel-to-mesh lookup, including its validity mask."""
digest = hashlib.sha256()
for array in (flat.positions_mm, flat.face_index):
digest.update(str((array.shape, array.dtype.str)).encode('ascii'))
digest.update(np.ascontiguousarray(array).tobytes())
return digest.hexdigest()
def load_uv_texture(path: str | Path) -> np.ndarray | None:
"""Decode a UV-space texture to ``[H, W, 4]`` uint8, or ``None``.
@@ -0,0 +1,124 @@
"""Read-only diagnostics of recorded hold windows, never runtime calibration."""
from __future__ import annotations
import csv
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import numpy as np
from .calibration_session import write_json
def _rows(path: Path) -> list[dict]:
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def review_session(directory: Path) -> dict:
"""Join frame identity to host timestamps and inspect only complete windows.
Manual reference values are declarations. Fits remain exploratory regardless
of sample count, and replay timestamps cannot establish physical alignment.
"""
root = Path(directory).resolve()
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
if manifest.get("format_version") != "autocal-session-1.0":
raise ValueError("select a calibration session root, not an individual trial")
windows = []
issues = []
for trial in manifest["trials"]:
trial_id = trial["trial_id"]
if trial["status"] != "complete":
issues.append({"trial_id": trial_id, "reason": "incomplete_trial"})
continue
path = (root / trial["directory"]).resolve()
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"))
if (metadata.get("trial_status") != "complete"
or metadata.get("trial_id") != trial_id
or metadata.get("site_id") != trial["site_id"]
or metadata.get("calibration_session_id") != manifest["session_id"]
or metadata.get("uv_definition") != manifest["uv_definition"]):
raise ValueError(f"trial identity or completion mismatch: {trial_id}")
frames = {}
for row in _rows(path / "frames.csv"):
if row["dropped"] != "0" or not row["t_host_mono_ns"]:
continue
frame_id = int(row["frame_id"])
if frame_id in frames:
raise ValueError(f"duplicate frame identity in {trial_id}")
frames[frame_id] = int(row["t_host_mono_ns"])
samples = []
for row in _rows(path / "forces.csv"):
if row["valid"] != "1" or not row["FN_raw"]:
continue
if int(row["session_id"]) != metadata["session_id"]:
raise ValueError(f"acquisition session mismatch in {trial_id}")
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))
for reference in _rows(path / "reference.csv"):
if reference["hold_complete"] != "1":
continue
start = int(reference["t_start_host_mono_ns"])
end = int(reference["t_end_host_mono_ns"])
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)
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),
"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",
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"status": "candidate" if raw.size >= 2 else "insufficient_samples",
})
sites = []
for site in manifest["sites"]:
selected = [w for w in windows if w["site_id"] == site["site_id"]]
# Loading and unloading stay separate: a shared runtime piecewise curve
# 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})
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,
}
def save_review(result: dict) -> Path:
"""Save a new derived artifact; never replace acquired measurements."""
root = Path(result["source_directory"])
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)
return path
+114 -97
View File
@@ -9,14 +9,15 @@ from __future__ import annotations
import csv
import hashlib
import json
import re
import math
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from .calibration_session import CalibrationSession, write_json
from .sensor.uv_flat import Site
REFERENCE_HEADER = (
"step_index",
@@ -31,56 +32,10 @@ REFERENCE_HEADER = (
"force_frame",
"source",
"stability_verified",
"hold_complete",
)
def _folder_part(value: str) -> str:
return re.sub(r"[^\w-]+", "-", value.strip()).strip("-_ ")[:48]
@dataclass(frozen=True)
class Site:
u_px: int
v_px: int
position_sensor_mm: tuple[float, float, float]
face_index: int
raster_xy_px: tuple[int, int]
uv_origin_raster_xy_px: tuple[int, int]
def uv_origin(flat) -> tuple[int, int]:
"""Lowest valid pixel in the valid column nearest the image midpoint."""
rows, columns = np.nonzero(flat.face_index >= 0)
if not len(rows):
raise ValueError("UV map has no valid sensor pixels")
middle = (flat.width - 1) / 2
column = int(columns[np.argmin(np.abs(columns - middle))])
row = int(rows[columns == column].max())
return column, row
def site_at(
flat, u_px: int, v_px: int, *, origin: tuple[int, int] | None = None
) -> Site:
"""Resolve a precise raster pixel; empty mask pixels cannot become sites."""
if flat is None:
raise ValueError("UV map is not loaded")
origin_x, origin_y = origin if origin is not None else uv_origin(flat)
raster_x, raster_y = origin_x + int(u_px), origin_y - int(v_px)
sample = flat.sample(raster_x, raster_y)
if sample is None:
raise ValueError("UV pixel is outside the valid sensor surface")
point, _normal, face = sample
return Site(
int(u_px),
int(v_px),
tuple(float(v) for v in point),
int(face),
(raster_x, raster_y),
(origin_x, origin_y),
)
def load_path(levels: list[float]) -> list[tuple[str, float]]:
"""One continuous ascent and descent, ending at zero."""
values = [float(value) for value in levels]
@@ -88,7 +43,7 @@ def load_path(levels: list[float]) -> list[tuple[str, float]]:
raise ValueError("load levels must be in (0, 30] N")
if values != sorted(set(values)):
raise ValueError("load levels must be strictly increasing")
return [("load", value) for value in values] + [
return [("zero", 0.0)] + [("load", value) for value in values] + [
("unload", value) for value in [*reversed(values[:-1]), 0.0]
]
@@ -98,6 +53,11 @@ class ManualTrial:
def __init__(self, worker) -> None:
self.worker = worker
self.session: CalibrationSession | None = None
self.entry: dict | None = None
self.hold_s = 20.0
self.completed_steps: list[dict] = []
self.reason = ""
self.recorder: ExperimentRecorder | None = None
self.directory: Path | None = None
self.steps: list[tuple[str, float]] = []
@@ -133,18 +93,12 @@ class ManualTrial:
raise ValueError("sensor SN is required")
if not sensor_model.strip():
raise ValueError("sensor model is required")
model_part = _folder_part(sensor_model)
sn_part = _folder_part(sensor_sn)
if not model_part or not sn_part:
raise ValueError(
"sensor model and SN need letters or numbers for the folder"
)
label = f"{model_part}_{sn_part}"
if not float(fps) > 0.0:
if not math.isfinite(float(fps)) or not float(fps) > 0.0:
raise ValueError("input frame rate must be positive")
self.steps = load_path(levels)
steps = load_path(levels)
if not 10.0 <= float(hold_s) <= 30.0:
raise ValueError("hold time must be between 10 and 30 seconds")
self.hold_s = float(hold_s)
packet = self.worker.latest()
if packet.capture_mono_ns is None:
raise RuntimeError("the input has no frame-bound host timestamp")
@@ -167,24 +121,45 @@ class ManualTrial:
"uv_origin_raster_xy_px": list(site.uv_origin_raster_xy_px),
"bundle_path": bundle_path,
"mesh_sha256": mesh_sha256,
"raster_size_hw": list(site.raster_size_hw),
},
"plan": {"levels_n": levels, "hold_s": float(hold_s)},
"uv_definition": {
"unit": "px", "u_positive": "right", "v_positive": "up",
"origin_raster_xy_px": list(site.uv_origin_raster_xy_px),
"raster_size_hw": list(site.raster_size_hw),
"mapping_sha256": site.mapping_sha256,
},
"plan": {"levels_n": levels, "hold_s": float(hold_s), "steps": [
{"step_index": i, "branch": branch, "target_load_n": target}
for i, (branch, target) in enumerate(steps)
]},
"input_source": source,
"config_ref": config_ref,
"config_sha256": config_sha256,
"config_snapshot": json.loads(config_path.read_text(encoding="utf-8")),
"time_basis": packet.time_basis,
"reference_alignment_possible": self.reference_alignment_possible,
"reference_source": "manual_operator_confirmation",
"reference_file": "reference.csv",
}
recorder = ExperimentRecorder(
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0)
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True)
)
if self.session is None:
self.session = CalibrationSession(Path(root).resolve(), metadata)
elif Path(root).resolve() != self.session.directory.parent:
raise ValueError("finish this session before changing the output root")
entry = self.session.add_trial(metadata)
metadata.update({
"calibration_session_id": self.session.document["session_id"],
"trial_id": entry["trial_id"], "site_id": entry["site_id"],
"repeat_index": entry["repeat_index"], "trial_status": "incomplete",
})
directory = recorder.start(
root=Path(root),
root=self.session.directory / "trials",
session_id=packet.session_id,
metadata=metadata,
directory_label=label,
directory_name=entry["trial_id"],
)
reference_file = None
try:
@@ -199,6 +174,10 @@ class ManualTrial:
reference_file.close()
recorder.stop(wait=True)
raise
self.entry = entry
self.steps = steps
self.completed_steps = []
self.reason = ""
self.recorder = recorder
self.directory = directory
self._reference_file = reference_file
@@ -236,14 +215,15 @@ class ManualTrial:
recorder.log_event(
kind,
json.dumps(
{**detail, "t_host_mono_ns": now_mono, "t_host_utc_ns": time.time_ns()},
{**detail, "t_host_mono_ns": now_mono,
"t_host_utc_ns": time.time_ns() + now_mono - time.perf_counter_ns()},
ensure_ascii=False,
),
mono_t=event_mono_t,
frame_id=frame_id,
)
def _end_reference(self, end_ns: int) -> None:
def _end_reference(self, end_ns: int, *, complete: bool) -> None:
if self._active_reference is None:
return
step_index, branch, target, reference, start_ns = self._active_reference
@@ -262,6 +242,7 @@ class ManualTrial:
"",
"manual_operator_confirmation",
0,
int(complete),
)
)
self._reference_file.flush()
@@ -278,22 +259,8 @@ class ManualTrial:
if not 0.0 <= reference <= 30.0:
raise ValueError("reference load must be between 0 and 30 N")
now_ns = time.perf_counter_ns()
if self._last_confirm_ns is not None:
self._end_reference(now_ns)
if self._reference_error:
self.finish(
aborted=True,
reason=f"reference write failed: {self._reference_error}",
)
raise OSError(self._reference_error)
self._event(
"step_ended",
{
"step_index": self.step_index - 1,
"held_s": (now_ns - self._last_confirm_ns) / 1e9,
},
now_ns=now_ns,
)
if self._active_reference is not None:
raise RuntimeError("wait for the current hold to finish")
self._event(
"step_reached",
{
@@ -306,15 +273,51 @@ class ManualTrial:
now_ns=now_ns,
)
self._active_reference = (self.step_index, branch, target, reference, now_ns)
self.step_index += 1
self._last_confirm_ns = now_ns
self._event("hold_started", {"step_index": self.step_index}, now_ns=now_ns)
return branch, target
@property
def held_s(self) -> float | None:
if self._last_confirm_ns is None or self.status != "recording":
return None
return (time.perf_counter_ns() - self._last_confirm_ns) / 1e9
def holding(self) -> bool:
return self._active_reference is not None
@property
def remaining_s(self) -> float:
if not self.holding:
return 0.0
return max(0.0, self.hold_s - (time.perf_counter_ns() - self._last_confirm_ns) / 1e9)
def _complete_hold(self, end_ns: int, *, complete: bool) -> None:
index, branch, target, reference, start_ns = self._active_reference
self._end_reference(end_ns, complete=complete)
step = {
"step_index": index, "branch": branch, "target_load_n": target,
"reference_load_n": reference,
"t_start_host_mono_ns": start_ns, "t_end_host_mono_ns": end_ns,
"status": "complete" if complete and not self._reference_error else "incomplete",
"stability_verified": False,
}
self.completed_steps.append(step)
self._event("hold_ended", step, now_ns=end_ns)
self._last_confirm_ns = None
if complete and not self._reference_error:
self.step_index += 1
if self.session is not None and self.entry is not None:
self.entry["steps"] = list(self.completed_steps)
try:
self.session.save()
except OSError as exc:
self._reference_error = f"session write failed: {exc}"
def finish_session(self) -> tuple[Path, str]:
if self.status in {"recording", "closing"}:
raise RuntimeError("finish the current trial first")
if self.session is None:
raise RuntimeError("no calibration session")
directory = self.session.directory
status = self.session.close()
self.session = None
return directory, status
def finish(self, *, aborted: bool = False, reason: str = "") -> None:
if self.status != "recording" or self.recorder is None:
@@ -322,16 +325,8 @@ class ManualTrial:
if not aborted and self.next_step is not None:
raise RuntimeError("complete the load/unload path before finishing")
now_ns = time.perf_counter_ns()
if self._last_confirm_ns is not None:
self._end_reference(now_ns)
self._event(
"step_ended",
{
"step_index": self.step_index - 1,
"held_s": (now_ns - self._last_confirm_ns) / 1e9,
},
now_ns=now_ns,
)
if self.holding:
self._complete_hold(now_ns, complete=False)
try:
self._reference_file.close()
except OSError as exc:
@@ -344,6 +339,7 @@ class ManualTrial:
{"reason": reason},
now_ns=now_ns,
)
self.reason = reason
self.aborted = aborted
self.worker.remove_sink(self.recorder.on_packet)
self.recorder.stop(wait=False)
@@ -353,6 +349,11 @@ class ManualTrial:
if self.status == "recording" and self.recorder is not None:
if not self.worker.is_running or not self.recorder.is_recording:
self.finish(aborted=True, reason="input or writer stopped")
elif self.holding and self.remaining_s <= 0:
deadline = self._last_confirm_ns + round(self.hold_s * 1e9)
self._complete_hold(deadline, complete=True)
if self._reference_error:
self.finish(aborted=True, reason=self._reference_error)
if self.status == "closing" and self.recorder is not None:
report = self.recorder.report
if report is not None:
@@ -370,4 +371,20 @@ class ManualTrial:
)
else "incomplete"
)
self.entry.update({
"status": self.status,
"reason": self.reason or ("" if self.status == "complete" else "recording integrity check failed"),
"steps": list(self.completed_steps),
})
try:
trial_path = self.directory / "session.json"
document = json.loads(trial_path.read_text(encoding="utf-8"))
document.update({"trial_status": self.status, "steps": self.completed_steps,
"trial_reason": self.entry["reason"]})
write_json(trial_path, document)
self.session.save()
except (OSError, ValueError):
self.status = "incomplete"
self.entry["status"] = "incomplete"
raise
return self.status
@@ -1,7 +1,8 @@
"""Single-site manual finger trial: precise UV site, live view and recording."""
"""Manual finger session: precise UV sites, live view and trial recording."""
from __future__ import annotations
import logging
from pathlib import Path
from orisys_app_shared.i18n import t
@@ -23,7 +24,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..trial import ManualTrial, site_at, uv_origin
from ..sensor.uv_flat import site_at, uv_origin
from ..trial import ManualTrial, load_path
from .sensor_panel import FlatSurfaceView
@@ -63,6 +65,19 @@ class CalibrationPanel(QWidget):
outer.addLayout(header)
self.source_state = QLabel(self)
outer.addWidget(self.source_state)
session_row = QHBoxLayout()
self.session_label = QLabel(self)
self.session_label.setWordWrap(True)
self.close_session_button = QPushButton(self)
self.close_session_button.setObjectName("calibrationCloseSession")
self.close_session_button.clicked.connect(self._close_session)
self.review_button = QPushButton(self)
self.review_button.setObjectName("calibrationReview")
self.review_button.clicked.connect(self._review)
session_row.addWidget(self.session_label, 1)
session_row.addWidget(self.review_button)
session_row.addWidget(self.close_session_button)
outer.addLayout(session_row)
body = QHBoxLayout()
self.views = QTabWidget(self)
@@ -106,6 +121,9 @@ class CalibrationPanel(QWidget):
self.hold.setValue(20)
self.hold.setSuffix(" s")
form.addRow(self.hold_label, self.hold)
self.path_label = QLabel(controls)
self.path_label.setWordWrap(True)
form.addRow(self.path_label)
self.target_label = QLabel(controls)
form.addRow(self.target_label)
self.reference_label = QLabel(controls)
@@ -136,6 +154,7 @@ class CalibrationPanel(QWidget):
self.output_browse.clicked.connect(self._choose_output)
self.u_spin.valueChanged.connect(self._site_changed)
self.v_spin.valueChanged.connect(self._site_changed)
self.levels.textChanged.connect(self._sync)
self.start_button.clicked.connect(self._start)
self.confirm_button.clicked.connect(self._confirm)
self.finish_button.clicked.connect(self._finish)
@@ -148,6 +167,8 @@ class CalibrationPanel(QWidget):
self._sync()
def set_flat(self, flat, bundle_path: str) -> None:
if self.trial.session is not None:
raise RuntimeError(t("calibration.close_before_mapping"))
self._flat = flat
self._bundle_path = bundle_path
self.flat_view.set_flat(flat)
@@ -265,7 +286,12 @@ class CalibrationPanel(QWidget):
def _poll(self) -> None:
previous = self.trial.status
current = self.trial.poll()
try:
current = self.trial.poll()
except (OSError, ValueError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
self._sync()
return
if current != previous and current in {"complete", "incomplete"}:
result_key = (
"calibration.incomplete_result"
@@ -284,12 +310,46 @@ class CalibrationPanel(QWidget):
)
self._sync()
def _close_session(self) -> None:
try:
directory, status = self.trial.finish_session()
self.result_label.setText(
t("calibration.session_closed").format(
status=t(f"calibration.{status}"), path=directory
)
)
except (OSError, RuntimeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
self._sync()
def _review(self) -> None:
from .session_review_dialog import SessionReviewDialog
session = self.trial.session
directory = QFileDialog.getExistingDirectory(
self, t("review.open"),
str(session.directory) if session is not None else self.output_path.text(),
)
if directory:
try:
dialog = SessionReviewDialog(Path(directory), self)
dialog.exec()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
def shutdown(self) -> None:
self._timer.stop()
if self.trial.status == "recording":
self.trial.finish(aborted=True, reason="application closed")
if self.trial.status == "closing" and self.trial.recorder is not None:
self.trial.recorder.stop(wait=True)
try:
if self.trial.status == "recording":
self.trial.finish(aborted=True, reason="application closed")
if self.trial.status == "closing" and self.trial.recorder is not None:
self.trial.recorder.stop(wait=True)
self.trial.poll()
if self.trial.session is not None:
self.trial.finish_session()
except (OSError, RuntimeError, ValueError) as exc:
# A failed manifest must not prevent the window stopping the equipment.
logging.getLogger(__name__).exception("Calibration shutdown failed: %s", exc)
def _sync(self) -> None:
recording = self.trial.status == "recording"
@@ -299,21 +359,24 @@ class CalibrationPanel(QWidget):
self.v_spin,
self.levels,
self.hold,
self.sensor_sn,
self.sensor_model,
self.output_path,
self.output_browse,
self.bundle_button,
):
widget.setEnabled(not locked)
for widget in (self.sensor_sn, self.sensor_model, self.output_path,
self.output_browse, self.bundle_button):
widget.setEnabled(not locked and self.trial.session is None)
self.start_button.setEnabled(not locked and self._site is not None)
self.confirm_button.setEnabled(recording and self.trial.next_step is not None)
can_confirm = recording and self.trial.next_step is not None and not self.trial.holding
self.confirm_button.setEnabled(can_confirm)
self.reference.setEnabled(can_confirm)
self.finish_button.setEnabled(recording and self.trial.next_step is None)
self.abort_button.setEnabled(recording)
step = self.trial.next_step
if recording and step is not None:
target_text = t("calibration.next_step").format(
branch=t(f"calibration.{step[0]}"), load=step[1]
key = "calibration.holding" if self.trial.holding else "calibration.next_step"
target_text = t(key).format(
branch=t(f"calibration.{step[0]}"), load=step[1],
seconds=self.trial.remaining_s,
)
elif recording:
target_text = t("calibration.ready_to_finish")
@@ -328,11 +391,22 @@ class CalibrationPanel(QWidget):
total=len(self.trial.steps),
status=t(f"calibration.{self.trial.status}"),
)
+ (
t("calibration.held").format(seconds=self.trial.held_s)
if self.trial.held_s is not None
else ""
)
)
try:
steps = load_path([float(v.strip()) for v in self.levels.text().split(",")])
self.path_label.setText(t("calibration.path").format(
path=" → ".join(f"{t('calibration.' + branch)} {force:g}" for branch, force in steps)
))
except ValueError:
self.path_label.setText(t("calibration.invalid_levels"))
session = self.trial.session
self.close_session_button.setEnabled(session is not None and not locked)
self.review_button.setEnabled(not locked)
self.session_label.setText(
t("calibration.session_active").format(
path=session.directory, sites=len(session.document["sites"]),
trials=len(session.document["trials"]),
) if session is not None else t("calibration.session_none")
)
worker = self.source_panel.worker
packet = worker.latest()
@@ -353,6 +427,9 @@ class CalibrationPanel(QWidget):
self.sn_label.setText(t("calibration.sensor_sn"))
self.output_label.setText(t("calibration.output"))
self.output_browse.setText(t("source.browse"))
self.close_session_button.setText(t("calibration.close_session"))
self.close_session_button.setToolTip(t("calibration.close_session_tip"))
self.review_button.setText(t("review.open"))
self.views.setTabText(0, t("calibration.site_view"))
self.views.setTabText(1, t("calibration.live_view"))
self.bundle_button.setText(t("finger.load"))
@@ -18,6 +18,7 @@ not.
from __future__ import annotations
import csv
import math
from pathlib import Path
@@ -28,6 +29,7 @@ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QFileDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
@@ -45,7 +47,7 @@ from PySide6.QtWidgets import (
from ..motion.kinematics import PlatformStack
from ..sensor.export_bundle import SensorBundle
from ..sensor.frames import describe as describe_frames
from ..sensor.uv_flat import FlatSurface
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
#: How many picked points the list keeps before the oldest is dropped.
MAX_POINTS = 200
@@ -237,7 +239,7 @@ class FingerPanel(QGroupBox):
#: than typed into.
COLUMNS: tuple[str, ...] = (
"x", "y", "z", "nx", "ny", "nz",
"X", "Y", "Z", "yaw", "pitch", "residual",
"X", "Y", "Z", "yaw", "pitch", "residual", "u_px", "v_px",
)
#: How many of ``COLUMNS`` are inputs rather than solved outputs.
INPUT_COLUMNS = 6
@@ -613,8 +615,12 @@ class FingerPanel(QGroupBox):
self.up_button.clicked.connect(lambda: self.move_selected(-1))
self.down_button = QPushButton(box)
self.down_button.clicked.connect(lambda: self.move_selected(1))
self.export_sites_button = QPushButton(box)
self.export_sites_button.setObjectName("exportCalibrationSites")
self.export_sites_button.clicked.connect(self._export_sites_dialog)
for button in (self.clear_button, self.remove_button, self.up_button, self.down_button):
row.addWidget(button)
row.addWidget(self.export_sites_button)
row.addStretch(1)
self._selected_label = QLabel(box)
self._selected_label.setObjectName("hintLabel")
@@ -808,6 +814,50 @@ class FingerPanel(QGroupBox):
"""Every picked point, at full precision, for a later export."""
return [dict(row) for row in self._picked]
def export_sites(self, path: str | Path) -> Path:
"""Export the displayed order, with the same UV convention as calibration."""
flat = self.view.flat
if flat is None or not self._picked:
raise ValueError(t("finger.export_no_points"))
origin_x, origin_y = uv_origin(flat)
identity = mapping_sha256(flat)
rows = []
for order, record in enumerate(self._picked, 1):
site = site_at(
flat, record["u_px"], record["v_px"], origin=(origin_x, origin_y),
mapping_digest=identity,
)
point = [record[key] for key in ("x", "y", "z")]
if not np.allclose(point, site.position_sensor_mm, rtol=0, atol=1e-9):
raise ValueError(t("finger.export_edited_point").format(order=order))
rows.append({
"order": order, "site_id": f"site-{order:03d}",
"u_px": site.u_px, "v_px": site.v_px,
**{f"{axis}_sensor_mm": record[axis] for axis in ("x", "y", "z")},
**{f"{axis.lower()}_platform_mm": record[axis] for axis in ("X", "Y", "Z")},
"yaw_deg": record["yaw"], "pitch_deg": record["pitch"],
"origin_raster_x_px": origin_x, "origin_raster_y_px": origin_y,
"raster_width_px": flat.width, "raster_height_px": flat.height,
"u_positive": "right", "v_positive": "up", "mapping_sha256": identity,
})
destination = Path(path)
with destination.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
return destination
def _export_sites_dialog(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, t("finger.export_sites"), "calibration-sites.csv", "CSV (*.csv)"
)
if path:
try:
self.export_sites(path)
self.status_label.setText(t("finger.sites_exported").format(path=path))
except (OSError, ValueError) as exc:
self.status_label.setText(t("calibration.error").format(detail=exc))
# ------------------------------------------------------------------ actions
def handle_pick(self, x: int, y: int) -> dict[str, float] | None:
@@ -825,6 +875,7 @@ class FingerPanel(QGroupBox):
return None
point, normal, face = sample
origin_x, origin_y = uv_origin(self.view.flat)
pose = self._stack.solve(point, normal)
# The click leaves a cross-hair where it landed *and* promotes the new row
# to the selection, so the red dot marks the same point in both views.
@@ -839,6 +890,7 @@ class FingerPanel(QGroupBox):
"Z": float(pose.contact_z_mm),
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
"residual": float(pose.residual_deg),
"u_px": x - origin_x, "v_px": origin_y - y,
}
self._append_row(row)
self._last_pose = pose
@@ -896,7 +948,9 @@ class FingerPanel(QGroupBox):
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
item.setForeground(QColor("#5c6678"))
self.table.setItem(index, column, item)
item.setText(f"{row[key]:.3f}")
item.setText(
f"{row[key]:.0f}" if key in {"u_px", "v_px"} else f"{row[key]:.3f}"
)
self._ensure_selector(index)
finally:
self._writing = False
@@ -1269,6 +1323,8 @@ class FingerPanel(QGroupBox):
[""] + [t(f"finger.col_{key}") for key in self._headers]
)
self.clear_button.setText(t("finger.clear_points"))
self.export_sites_button.setText(t("finger.export_sites"))
self.export_sites_button.setToolTip(t("finger.export_sites_tip"))
self.remove_button.setText(t("finger.remove_selected"))
self.up_button.setText(t("finger.move_up"))
self.down_button.setText(t("finger.move_down"))
@@ -0,0 +1,168 @@
"""Exploratory hold-window table and per-site loading/unloading charts."""
from __future__ import annotations
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.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout,
)
from ..session_review import review_session, save_review
class SessionReviewDialog(QDialog):
"""Open persisted data independently of the acquisition page's state."""
def __init__(self, directory: Path, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(t("review.title"))
self.resize(1080, 760)
self.review = review_session(directory)
layout = QVBoxLayout(self)
self.notice = QLabel(t("review.notice"), self)
self.notice.setWordWrap(True)
layout.addWidget(self.notice)
row = QHBoxLayout()
self.sites = QComboBox(self)
self.sites.setObjectName("reviewSite")
for site in self.review["sites"]:
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)
row.addWidget(self.sites, 1)
row.addWidget(self.save_button)
layout.addLayout(row)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
self.chart = QChart()
self.chart_view = QChartView(self.chart, self)
self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.uv_chart = QChart()
self.uv_view = QChartView(self.uv_chart, self)
self.uv_view.setRenderHint(QPainter.RenderHint.Antialiasing)
charts = QHBoxLayout()
charts.addWidget(self.uv_view, 1)
charts.addWidget(self.chart_view, 2)
layout.addLayout(charts, 1)
self.table = QTableWidget(0, 8, self)
self.table.setObjectName("reviewWindows")
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setHorizontalHeaderLabels([t(f"review.col_{key}") for key in (
"trial", "branch", "load", "samples", "mean", "std", "duration", "quality")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table, 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._show_site()
def _show_site(self) -> None:
index = self.sites.currentIndex()
self.chart.removeAllSeries()
for axis in self.chart.axes():
self.chart.removeAxis(axis)
if index < 0:
self.summary.setText(t("review.no_sites"))
return
site = self.review["sites"][index]
windows = site["windows"]
self.table.setRowCount(len(windows))
for row, window in enumerate(windows):
quality = t("review." + window["status"])
if not window["reference_alignment_possible"]:
quality += " · " + t("review.replay")
if not window["stability_verified"]:
quality += " · " + t("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"]
self.summary.setText(
t("review.fit").format(**fit) if fit else t("review.no_fit")
)
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
series = QScatterSeries()
series.setName(t("calibration." + branch))
series.setColor(QColor(color))
series.setMarkerSize(9)
for window in windows:
if window["branch"] == branch and window["status"] == "candidate":
series.append(window["raw_mean"], window["reference_n"])
if series.count():
self.chart.addSeries(series)
if fit:
raw = [w["raw_mean"] for w in windows if w["status"] == "candidate"]
line = QLineSeries()
line.setName(t("review.fit_line"))
for value in (min(raw), max(raw)):
line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"])
self.chart.addSeries(line)
self.chart.createDefaultAxes()
for axis in self.chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText(t("review.raw_axis"))
for axis in self.chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.force_axis"))
self._pad_axes(self.chart)
self.chart.setTitle(t("review.chart_title"))
self._show_uv(index)
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
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"]]
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"]
ratio = (value - low) / (high - low) if high > low else .5
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
series.setColor(color)
series.setBorderColor(QColor("#151515") if index == selected else color)
series.setMarkerSize(15 if index == selected else 10)
series.append(*site["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target))
self.uv_chart.addSeries(series)
self.uv_chart.createDefaultAxes()
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)")
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)")
self._pad_axes(self.uv_chart)
self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("review.uv_title").format(low=low, high=high))
@staticmethod
def _pad_axes(chart: QChart) -> None:
for axis in chart.axes():
low, high = axis.min(), axis.max()
margin = max((high - low) * .1, .1)
axis.setRange(low - margin, high + margin)
@staticmethod
def _number(value) -> str:
return "—" if value is None else f"{value:.6g}"
def _save(self) -> None:
try:
path = save_review(self.review)
self.message.setText(t("review.saved").format(path=path))
except OSError as exc:
self.message.setText(t("calibration.error").format(detail=exc))
@@ -406,6 +406,8 @@ class AutoCalMainWindow(QMainWindow):
export, a missing file or an unreadable mesh must leave the rest of the
app alone and report the reason.
"""
if self.calibration_panel.trial.session is not None:
raise ValueError(t("calibration.close_before_mapping"))
bundle = load_bundle(
path,
# The design profile declares these for this revision; a mismatch