69c1322616
将人工参考载荷按档位区间保存,重排帧字段并明确界面操作。已通过相关测试、代码和文档检查。
374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""One manually confirmed finger load/unload trial.
|
|
|
|
The acquisition worker owns frames; the shared recorder owns disk I/O. This
|
|
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
|
|
|
|
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
|
|
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]
|
|
if not values or any(not 0.0 < value <= 30.0 for value in values):
|
|
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] + [
|
|
("unload", value) for value in [*reversed(values[:-1]), 0.0]
|
|
]
|
|
|
|
|
|
class ManualTrial:
|
|
"""Connect a running finger source to one independent recording directory."""
|
|
|
|
def __init__(self, worker) -> None:
|
|
self.worker = worker
|
|
self.recorder: ExperimentRecorder | None = None
|
|
self.directory: Path | None = None
|
|
self.steps: list[tuple[str, float]] = []
|
|
self.step_index = 0
|
|
self.status = "idle"
|
|
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,
|
|
*,
|
|
root: Path,
|
|
site: Site,
|
|
levels: list[float],
|
|
hold_s: float,
|
|
sensor_model: str,
|
|
sensor_sn: str,
|
|
bundle_path: str,
|
|
source: dict,
|
|
config_ref: str,
|
|
fps: float,
|
|
) -> Path:
|
|
if self.status in {"recording", "closing"}:
|
|
raise RuntimeError("a trial is already active")
|
|
if not self.worker.is_running or self.worker.latest() is None:
|
|
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)
|
|
if not 10.0 <= float(hold_s) <= 30.0:
|
|
raise ValueError("hold time must be between 10 and 30 seconds")
|
|
packet = self.worker.latest()
|
|
if packet.capture_mono_ns is None:
|
|
raise RuntimeError("the input has no frame-bound host timestamp")
|
|
mesh_path = Path(bundle_path) / "mesh.npz"
|
|
config_path = Path(config_ref)
|
|
if not mesh_path.is_file() or not config_path.is_file():
|
|
raise ValueError("sensor mesh and configuration files are required")
|
|
mesh_sha256 = hashlib.sha256(mesh_path.read_bytes()).hexdigest()
|
|
config_sha256 = hashlib.sha256(config_path.read_bytes()).hexdigest()
|
|
self.reference_alignment_possible = packet.time_basis == "host_camera"
|
|
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),
|
|
"face_index": site.face_index,
|
|
"raster_xy_px": list(site.raster_xy_px),
|
|
"uv_origin_raster_xy_px": list(site.uv_origin_raster_xy_px),
|
|
"bundle_path": bundle_path,
|
|
"mesh_sha256": mesh_sha256,
|
|
},
|
|
"plan": {"levels_n": levels, "hold_s": float(hold_s)},
|
|
"input_source": source,
|
|
"config_ref": config_ref,
|
|
"config_sha256": config_sha256,
|
|
"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,
|
|
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
|
|
self.status = "recording"
|
|
self.worker.add_sink(recorder.on_packet)
|
|
self._event("trial_started", {"site_uv_xy_px": [site.u_px, site.v_px]})
|
|
return directory
|
|
|
|
@property
|
|
def next_step(self) -> tuple[str, float] | None:
|
|
return (
|
|
self.steps[self.step_index] if self.step_index < len(self.steps) else None
|
|
)
|
|
|
|
def _event(self, kind: str, detail: dict, *, now_ns: int | None = None) -> None:
|
|
recorder = self.recorder
|
|
if recorder is None:
|
|
return
|
|
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 = (
|
|
float(packet.mono_t or 0.0) + (now_mono - packet.capture_mono_ns) / 1e9
|
|
)
|
|
frame_id = packet.frame_id
|
|
else:
|
|
event_mono_t = None
|
|
frame_id = -1
|
|
recorder.log_event(
|
|
kind,
|
|
json.dumps(
|
|
{**detail, "t_host_mono_ns": now_mono, "t_host_utc_ns": time.time_ns()},
|
|
ensure_ascii=False,
|
|
),
|
|
mono_t=event_mono_t,
|
|
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")
|
|
branch, target = self.next_step
|
|
reference = float(reference_n)
|
|
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,
|
|
)
|
|
self._event(
|
|
"step_reached",
|
|
{
|
|
"step_index": self.step_index,
|
|
"branch": branch,
|
|
"target_load_n": target,
|
|
"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
|
|
|
|
@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 finish(self, *, aborted: bool = False, reason: str = "") -> None:
|
|
if self.status != "recording" or self.recorder is None:
|
|
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": (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},
|
|
now_ns=now_ns,
|
|
)
|
|
self.aborted = aborted
|
|
self.worker.remove_sink(self.recorder.on_packet)
|
|
self.recorder.stop(wait=False)
|
|
self.status = "closing"
|
|
|
|
def poll(self) -> str:
|
|
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")
|
|
if self.status == "closing" and self.recorder is not None:
|
|
report = self.recorder.report
|
|
if report is not None:
|
|
self.status = (
|
|
"complete"
|
|
if (
|
|
report.ok
|
|
and report.stats.frames > 0
|
|
and report.stats.video_frames > 0
|
|
and report.stats.segments == 1
|
|
and report.stats.dropped_data == 0
|
|
and report.stats.dropped_video == 0
|
|
and not self.aborted
|
|
and self.next_step is None
|
|
)
|
|
else "incomplete"
|
|
)
|
|
return self.status
|