完善 Autocal 多测点标定流程及会话复核
统一测点坐标、保持窗口和试验目录,补齐逐点初步分析。定向测试与真实视频闭环通过;两项轮询失败在修改前版本同样复现。
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user