实现 finger 单测点手动试验采集

将精确 UV 选点、实时预览与逐档人工确认接入同一页面,并保存每次试验的视频、事件和帧采集时间。通过离线视频、页面及记录回归测试。
This commit is contained in:
ykcheungab
2026-10-11 15:55:45 +08:00
parent 32dcecb270
commit dc7e290489
12 changed files with 896 additions and 15 deletions
+263
View File
@@ -0,0 +1,263 @@
"""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 hashlib
import json
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
@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
def start(
self,
*,
root: Path,
site: Site,
levels: list[float],
hold_s: float,
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 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(),
"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",
}
recorder = ExperimentRecorder(
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0)
)
directory = recorder.start(
root=Path(root), session_id=packet.session_id, metadata=metadata
)
self.recorder = recorder
self.directory = directory
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) -> None:
recorder = self.recorder
if recorder is None:
return
now_mono = 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 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._event(
"step_ended",
{
"step_index": self.step_index - 1,
"held_s": (now_ns - self._last_confirm_ns) / 1e9,
},
)
self._event(
"step_reached",
{
"step_index": self.step_index,
"branch": branch,
"target_load_n": target,
"reference_load_n": reference,
"reference_kind": "manual",
},
)
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")
if self._last_confirm_ns is not None:
self._event(
"step_ended",
{"step_index": self.step_index - 1, "held_s": self.held_s},
)
self._event(
"trial_aborted" if aborted else "trial_finished", {"reason": reason}
)
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