调整 Autocal 标定记录格式与目录命名

将人工参考载荷按档位区间保存,重排帧字段并明确界面操作。已通过相关测试、代码和文档检查。
This commit is contained in:
ykcheungab
2026-10-11 16:21:49 +08:00
parent dc7e290489
commit 69c1322616
9 changed files with 233 additions and 46 deletions
+115 -5
View File
@@ -6,8 +6,10 @@ module owns only the site, planned steps and operator-confirmed event order.
from __future__ import annotations
import csv
import hashlib
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
@@ -16,6 +18,26 @@ import numpy as np
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
REFERENCE_HEADER = (
"step_index",
"branch",
"target_load_n",
"t_start_host_mono_ns",
"t_end_host_mono_ns",
"F_ref_normal_N",
"F_ref_x_N",
"F_ref_y_N",
"F_ref_z_N",
"force_frame",
"source",
"stability_verified",
)
def _folder_part(value: str) -> str:
return re.sub(r"[^\w-]+", "-", value.strip()).strip("-_ ")[:48]
@dataclass(frozen=True)
class Site:
u_px: int
@@ -84,6 +106,10 @@ class ManualTrial:
self.aborted = False
self.reference_alignment_possible = False
self._last_confirm_ns: int | None = None
self._reference_file = None
self._reference_writer = None
self._active_reference: tuple[int, str, float, float, int] | None = None
self._reference_error = ""
def start(
self,
@@ -92,6 +118,7 @@ class ManualTrial:
site: Site,
levels: list[float],
hold_s: float,
sensor_model: str,
sensor_sn: str,
bundle_path: str,
source: dict,
@@ -104,6 +131,15 @@ class ManualTrial:
raise RuntimeError("start the finger input and wait for its first frame")
if not sensor_sn.strip():
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:
raise ValueError("input frame rate must be positive")
self.steps = load_path(levels)
@@ -122,6 +158,7 @@ class ManualTrial:
metadata = {
"purpose": "autocal_finger_manual_trial",
"sensor_sn": sensor_sn.strip(),
"sensor_model": sensor_model.strip(),
"site": {
"uv_xy_px": [site.u_px, site.v_px],
"position_sensor_mm": list(site.position_sensor_mm),
@@ -138,15 +175,36 @@ class ManualTrial:
"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)
)
directory = recorder.start(
root=Path(root), session_id=packet.session_id, metadata=metadata
root=Path(root),
session_id=packet.session_id,
metadata=metadata,
directory_label=label,
)
reference_file = None
try:
reference_file = (directory / "reference.csv").open(
"w", newline="", encoding="utf-8"
)
reference_writer = csv.writer(reference_file)
reference_writer.writerow(REFERENCE_HEADER)
reference_file.flush()
except OSError:
if reference_file is not None:
reference_file.close()
recorder.stop(wait=True)
raise
self.recorder = recorder
self.directory = directory
self._reference_file = reference_file
self._reference_writer = reference_writer
self._active_reference = None
self._reference_error = ""
self.step_index = 0
self.aborted = False
self._last_confirm_ns = None
@@ -161,11 +219,11 @@ class ManualTrial:
self.steps[self.step_index] if self.step_index < len(self.steps) else None
)
def _event(self, kind: str, detail: dict) -> None:
def _event(self, kind: str, detail: dict, *, now_ns: int | None = None) -> None:
recorder = self.recorder
if recorder is None:
return
now_mono = time.perf_counter_ns()
now_mono = now_ns if now_ns is not None else time.perf_counter_ns()
packet = self.worker.latest()
if packet is not None and packet.capture_mono_ns is not None:
event_mono_t = (
@@ -185,6 +243,33 @@ class ManualTrial:
frame_id=frame_id,
)
def _end_reference(self, end_ns: int) -> None:
if self._active_reference is None:
return
step_index, branch, target, reference, start_ns = self._active_reference
try:
self._reference_writer.writerow(
(
step_index,
branch,
target,
start_ns,
end_ns,
reference,
"",
"",
"",
"",
"manual_operator_confirmation",
0,
)
)
self._reference_file.flush()
except OSError as exc:
self._reference_error = str(exc)
finally:
self._active_reference = None
def confirm(self, reference_n: float) -> tuple[str, float]:
if self.status != "recording" or self.next_step is None:
raise RuntimeError("no load step is waiting for confirmation")
@@ -194,12 +279,20 @@ class ManualTrial:
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,
)
self._event(
"step_reached",
@@ -210,7 +303,9 @@ class ManualTrial:
"reference_load_n": reference,
"reference_kind": "manual",
},
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
return branch, target
@@ -226,13 +321,28 @@ class ManualTrial:
raise RuntimeError("no active trial")
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": self.held_s},
{
"step_index": self.step_index - 1,
"held_s": (now_ns - self._last_confirm_ns) / 1e9,
},
now_ns=now_ns,
)
try:
self._reference_file.close()
except OSError as exc:
self._reference_error = str(exc)
if self._reference_error:
aborted = True
reason = f"reference write failed: {self._reference_error}"
self._event(
"trial_aborted" if aborted else "trial_finished", {"reason": reason}
"trial_aborted" if aborted else "trial_finished",
{"reason": reason},
now_ns=now_ns,
)
self.aborted = aborted
self.worker.remove_sink(self.recorder.on_packet)
@@ -43,6 +43,9 @@ class CalibrationPanel(QWidget):
outer = QVBoxLayout(self)
header = QHBoxLayout()
self.model_label = QLabel(self)
self.sensor_model = QLineEdit(self)
self.sensor_model.setObjectName("calibrationSensorModel")
self.sn_label = QLabel(self)
self.sensor_sn = QLineEdit(self)
self.sensor_sn.setObjectName("calibrationSensorSn")
@@ -50,6 +53,8 @@ class CalibrationPanel(QWidget):
self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self)
self.output_path.setObjectName("calibrationOutputPath")
self.output_browse = QPushButton(self)
header.addWidget(self.model_label)
header.addWidget(self.sensor_model)
header.addWidget(self.sn_label)
header.addWidget(self.sensor_sn)
header.addWidget(self.output_label)
@@ -214,6 +219,7 @@ class CalibrationPanel(QWidget):
site=self._site,
levels=levels,
hold_s=self.hold.value(),
sensor_model=self.sensor_model.text(),
sensor_sn=self.sensor_sn.text(),
bundle_path=self._bundle_path,
source=source,
@@ -239,21 +245,21 @@ class CalibrationPanel(QWidget):
raise ValueError(t("calibration.reference_required"))
self.trial.confirm(float(self.reference.text()))
self.reference.clear()
except (RuntimeError, ValueError) as exc:
except (OSError, RuntimeError, ValueError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
self._sync()
def _finish(self) -> None:
try:
self.trial.finish()
except RuntimeError as exc:
except (OSError, RuntimeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
self._sync()
def _abort(self) -> None:
try:
self.trial.finish(aborted=True, reason="operator aborted")
except RuntimeError as exc:
except (OSError, RuntimeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
self._sync()
@@ -289,6 +295,7 @@ class CalibrationPanel(QWidget):
self.levels,
self.hold,
self.sensor_sn,
self.sensor_model,
self.output_path,
self.output_browse,
self.bundle_button,
@@ -299,13 +306,17 @@ class CalibrationPanel(QWidget):
self.finish_button.setEnabled(recording and self.trial.next_step is None)
self.abort_button.setEnabled(recording)
step = self.trial.next_step
self.target_label.setText(
t("calibration.next_step").format(
if recording and step is not None:
target_text = t("calibration.next_step").format(
branch=t(f"calibration.{step[0]}"), load=step[1]
)
if recording and step is not None
else t("calibration.no_step")
)
elif recording:
target_text = t("calibration.ready_to_finish")
elif self.trial.status == "idle":
target_text = t("calibration.before_start")
else:
target_text = t("calibration.no_step")
self.target_label.setText(target_text)
self.progress_label.setText(
t("calibration.progress").format(
done=self.trial.step_index,
@@ -333,6 +344,7 @@ class CalibrationPanel(QWidget):
)
def retranslate(self) -> None:
self.model_label.setText(t("calibration.sensor_model"))
self.sn_label.setText(t("calibration.sensor_sn"))
self.output_label.setText(t("calibration.output"))
self.output_browse.setText(t("source.browse"))