diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index b5aed23..ce2e1ec 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -224,7 +224,7 @@ "tab_axes": "Axis debug", "tab_sequence": "Test sequence", "tab_finger": "Finger debug", - "tab_sensor": "Sensor", + "tab_sensor": "Calibration capture", "tab_surface": "Surface map", "tab_points": "Calibration points", "not_built": "Not built yet", @@ -244,7 +244,7 @@ "stop": "Stop", "idle": "Choose an input source and sensor.json", "no_frame": "Waiting for processed frames", - "preview_only": "Use this page to check the input image. Calibration trial recording is not connected yet.", + "preview_only": "Choose a U/V site first, then check the live 2D UV image here and record the trial.", "choose_video": "Choose Finger video", "choose_config": "Choose sensor.json", "video_filter": "Video files (*.mp4 *.avi *.mov *.mkv);;All files (*)", @@ -262,6 +262,40 @@ "basis_unknown": "read time (source unverified)", "frame": "Frame {frame} · source {source_frame} · {basis} {time} · dropped {dropped}" }, + "calibration": { + "sensor_sn": "Sensor SN", + "output": "Output directory", + "site_view": "Select site", + "live_view": "Live 2D UV image", + "uv": "U / V (px)", + "position": "Mesh position", + "levels": "Load levels (N)", + "hold": "Suggested hold", + "reference": "Actual reference (N)", + "start": "Start recording", + "confirm": "Confirm level", + "finish": "Finish trial", + "abort": "Abort and keep data", + "load": "Load", + "unload": "Unload", + "next_step": "Next: {branch} to {load:g} N", + "no_step": "No step pending", + "progress": "Progress {done}/{total} · {status}", + "held": " · Current level held {seconds:.1f} s", + "source_state": "Input: {status} · frame {frame} · capture drops {dropped}", + "replay_note": "Video replay is for debugging; manual reference loads cannot be synced to the original experiment.", + "idle": "Idle", + "recording": "Recording", + "closing": "Writing files", + "complete": "Complete", + "incomplete": "Incomplete", + "result": "{status}: {path}", + "error": "Action failed: {detail}", + "invalid_site": "Select a valid UV site first", + "start_input": "Start camera or video input first", + "reference_required": "Enter the actual reference load", + "output_required": "Choose an output directory" + }, "finger": { "title": "Finger calibration-point debug", "bundle_path": "Export bundle", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index d15ee04..ffa26d7 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -224,7 +224,7 @@ "tab_axes": "轴调试", "tab_sequence": "测试序列", "tab_finger": "手指调试", - "tab_sensor": "传感器", + "tab_sensor": "标定采集", "tab_surface": "表面分布", "tab_points": "标定点", "not_built": "尚未实现", @@ -244,7 +244,7 @@ "stop": "停止", "idle": "请选择输入来源和 sensor.json", "no_frame": "等待处理后的画面", - "preview_only": "此页用于检查输入画面;标定事件记录尚未接通。", + "preview_only": "先在选择测点页确定 U/V,再在此检查实时二维 UV 图像并开始试验记录。", "choose_video": "选择 Finger 视频", "choose_config": "选择 sensor.json", "video_filter": "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)", @@ -262,6 +262,40 @@ "basis_unknown": "读取时间(来源未确认)", "frame": "帧 {frame} · 源帧 {source_frame} · {basis} {time} · 丢帧 {dropped}" }, + "calibration": { + "sensor_sn": "传感器 SN", + "output": "保存目录", + "site_view": "选择测点", + "live_view": "实时二维 UV 图像", + "uv": "U / V (px)", + "position": "网格坐标", + "levels": "加载档位 (N)", + "hold": "建议保持", + "reference": "实际参考力 (N)", + "start": "开始记录", + "confirm": "确认到档", + "finish": "结束试验", + "abort": "中止并保留", + "load": "加载", + "unload": "卸载", + "next_step": "下一档:{branch}至 {load:g} N", + "no_step": "无待确认档位", + "progress": "进度 {done}/{total} · {status}", + "held": " · 当前档已保持 {seconds:.1f} s", + "source_state": "输入:{status} · 帧 {frame} · 采集丢帧 {dropped}", + "replay_note": "视频重放仅用于调试;本次人工参考力不能与原实验时间同步。", + "idle": "未开始", + "recording": "记录中", + "closing": "正在写盘", + "complete": "已完成", + "incomplete": "未完成", + "result": "{status}:{path}", + "error": "操作失败:{detail}", + "invalid_site": "请先选择有效的 UV 测点", + "start_input": "请先启动相机或视频输入", + "reference_required": "请输入实际参考力", + "output_required": "请选择保存目录" + }, "finger": { "title": "手指标定点调试", "bundle_path": "导出包", diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index 8138275..e438c7d 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -1,6 +1,6 @@ # Autocal 标定操作与数据格式草稿 -状态:待评审,2026-10-11。用途:方案及操作指南。阶段一只针对 finger,由操作员手动完成选点、设档、加载、记录和逐点灵敏度可视化;下列流程尚非全部可执行的软件说明。 +状态:待评审,2026-10-11。用途:方案及操作指南。阶段一只针对 finger;单测点、单 trial 手动记录已接通,跨点 session、FT 参考及灵敏度可视化尚未实现。下列目标文件清单不等同于当前写盘格式;当前每 trial 是一个独立目录,含 `raw_0001.mp4`、`session.json`、`frames.csv`、`forces.csv`、`events.csv`。 ## 范围与软件现状 @@ -41,8 +41,8 @@ | 对象 | 保存内容 | 约定 | |---|---|---| -| 测点 | `uv_xy`、`position_sensor_mm` | UV 单位 pixel,可为亚像素值;直接映射到 mesh 的 mm 位置 | -| UV 定义 | `uv_origin_px`、图像尺寸、轴正方向 | 原点在有效 mask 内底部中间;允许负 Y | +| 测点 | `uv_xy_px`、`raster_xy_px`、`position_sensor_mm` | 当前 U/V 为整数 pixel;网格坐标为 mm | +| UV 定义 | `uv_origin_raster_xy_px`、图像尺寸、轴正方向 | 当前取最靠近图像中线的有效列底部像素为原点;U 向右、V 向上,允许负 V | | 几何 | `geometry_id`、mesh/mask/映射文件及哈希 | 固定映射版本,二维与三维值可相互核对 | | 姿态 | `T_platform_from_sensor`、`pose_source`、激励方向 | 记录传感器→平台变换,区分实测和指令;normal 不必单独保存 | | 关联层级 | `session → site → trial → step → samples` | trial 是完整循环,step 是循环内档位;帧与参考流按时间关联 | @@ -113,7 +113,7 @@ }, "config_ref": "config/sensors/finger/sensor.json", "status": "incomplete", - "sites": [{"site_id": "site-001", "uv_xy": [120.5, -4.0], "position_sensor_mm": [1.2, 0.8, 3.4]}], + "sites": [{"site_id": "site-001", "uv_xy_px": [120, -4], "position_sensor_mm": [1.2, 0.8, 3.4]}], "trials": [{ "trial_id": "trial-001", "site_id": "site-001", diff --git a/apps/autocal/docs/implementation-plan.zh.md b/apps/autocal/docs/implementation-plan.zh.md index 60def03..75a6d72 100644 --- a/apps/autocal/docs/implementation-plan.zh.md +++ b/apps/autocal/docs/implementation-plan.zh.md @@ -1,6 +1,6 @@ # Autocal finger 标定实施计划 -状态:2026-10-11 修订。用途:方案。阶段一的交付为操作员手动控制完成整条标定流程;M1 已接通视频与相机编号输入,并把主机采集时间绑定到 finger 帧。相机实机尚未验收,会话记录及灵敏度页面尚未实现。操作与文件字段以[数据格式草稿](calibration-data-format.zh.md)为准。 +状态:2026-10-11 修订。用途:方案。阶段一的交付为操作员手动控制完成整条标定流程;M1 已接通视频与相机编号输入,并把主机采集时间绑定到 finger 帧。单测点手动 trial 已能记录;相机实机、跨点会话与灵敏度页面尚未验收。操作与目标文件字段以[数据格式草稿](calibration-data-format.zh.md)为准。 ## 阶段一目标 @@ -33,7 +33,9 @@ M1 的视频与相机输入现共用带时间的数据包;M2 记录器与 M3 手动 UI 可在接口固定后并行。M4 可先用合成 session 开发,但阶段一只有在真实记录上重新打开并计算逐测点结果才算完成。Studio 的 `SessionController`、widget 和 `RecordingCoordinator` 保留在 Studio;Autocal 直接复用 shared `FingerAcquisitionWorker`、`SdkPacket` 和 sink,不跨应用导入私有模块。只有出现两端实际共用的逻辑时才移到 shared;SDK 不依赖 UI。 -界面复核:当前“手指调试”用于选点,“测试序列”用于计划,“传感器”只检查输入,三个页面之间尚无 trial 状态贯通;全局“运行”按钮也尚未启动记录。M3 应让操作员按“选点→设档→开始记录→逐档确认→卸载→结束”前进,并在操作期间持续看到选中点、输入状态和记录状态。现阶段传感器页明确提示仅供预览,避免误以为已保存标定数据。 +界面复核:原先测点、计划和输入分散在“手指调试”“测试序列”“传感器”页。现在“标定采集”页串起单次 trial;全局“运行”按钮尚未负责标定,页面内有独立的“开始记录”。 + +已实现的最小闭环在“标定采集”页:点击 UV 图粗选、以 U/V 整数像素精确修改、切换到实时二维 UV 图像,逐档填写实际参考力并确认。每个 trial 单独使用 shared `ExperimentRecorder` 生成目录、视频、帧索引、力数据和事件;`frames.csv` 附加主机采集时间及来源帧编号。当前人工参考只记录到档事件,尚无可自动判定的稳定窗口;未实现 FT 同步、跨点 session 汇总、灵敏度拟合和实体相机验收。手动 Z 控制仍使用既有设备界面,专用弹窗需先确定操作边界。 主代理负责接口、M3 和集成;一名子代理负责 M1/M2,另一名负责 M4 与合成会话验证。建议总计主代理加两名子代理,约 4–6 个工程工作日并留设备联调时间;单人约 6–9 日。估算不包括设备、FT 协议或硬件等待。当前计划不启动子代理。 diff --git a/apps/autocal/src/orisys_autocal/trial.py b/apps/autocal/src/orisys_autocal/trial.py new file mode 100644 index 0000000..853e588 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/trial.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py new file mode 100644 index 0000000..65f2b8c --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -0,0 +1,351 @@ +"""Single-site manual finger trial: precise UV site, live view and recording.""" + +from __future__ import annotations + +from pathlib import Path + +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import outputs_dir +from PySide6.QtCore import QTimer, Signal +from PySide6.QtWidgets import ( + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from ..trial import ManualTrial, site_at, uv_origin +from .sensor_panel import FlatSurfaceView + + +class CalibrationPanel(QWidget): + """One operator page. No motion commands are issued by this widget.""" + + loadBundleRequested = Signal() + + def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.source_panel = source_panel + self.trial = ManualTrial(source_panel.worker) + self._flat = None + self._origin: tuple[int, int] | None = None + self._bundle_path = "" + self._site = None + + outer = QVBoxLayout(self) + header = QHBoxLayout() + self.sn_label = QLabel(self) + self.sensor_sn = QLineEdit(self) + self.sensor_sn.setObjectName("calibrationSensorSn") + self.output_label = QLabel(self) + self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self) + self.output_path.setObjectName("calibrationOutputPath") + self.output_browse = QPushButton(self) + header.addWidget(self.sn_label) + header.addWidget(self.sensor_sn) + header.addWidget(self.output_label) + header.addWidget(self.output_path, 1) + header.addWidget(self.output_browse) + outer.addLayout(header) + self.source_state = QLabel(self) + outer.addWidget(self.source_state) + + body = QHBoxLayout() + self.views = QTabWidget(self) + self.views.setObjectName("calibrationViews") + self.site_view = QWidget(self.views) + site_layout = QVBoxLayout(self.site_view) + self.flat_view = FlatSurfaceView(self.site_view) + self.flat_view.pixelPicked.connect(self._picked) + site_layout.addWidget(self.flat_view, 1) + self.bundle_button = QPushButton(self.site_view) + self.bundle_button.clicked.connect(self.loadBundleRequested) + site_layout.addWidget(self.bundle_button) + self.views.addTab(self.site_view, "") + self.views.addTab(self.source_panel, "") + body.addWidget(self.views, 3) + + controls = QGroupBox(self) + controls.setObjectName("groupBox") + form = QFormLayout(controls) + self.u_spin = QSpinBox(controls) + self.u_spin.setObjectName("calibrationU") + self.v_spin = QSpinBox(controls) + self.v_spin.setObjectName("calibrationV") + coords = QWidget(controls) + coord_row = QHBoxLayout(coords) + coord_row.setContentsMargins(0, 0, 0, 0) + coord_row.addWidget(self.u_spin) + coord_row.addWidget(self.v_spin) + self.coords_label = QLabel(controls) + form.addRow(self.coords_label, coords) + self.position_label = QLabel(controls) + self.position_value = QLabel("—", controls) + form.addRow(self.position_label, self.position_value) + self.levels_label = QLabel(controls) + self.levels = QLineEdit("5, 10, 20, 30", controls) + self.levels.setObjectName("calibrationLevels") + form.addRow(self.levels_label, self.levels) + self.hold_label = QLabel(controls) + self.hold = QDoubleSpinBox(controls) + self.hold.setRange(10, 30) + self.hold.setValue(20) + self.hold.setSuffix(" s") + form.addRow(self.hold_label, self.hold) + self.target_label = QLabel(controls) + form.addRow(self.target_label) + self.reference_label = QLabel(controls) + self.reference = QLineEdit(controls) + self.reference.setObjectName("calibrationReference") + form.addRow(self.reference_label, self.reference) + actions = QWidget(controls) + action_grid = QGridLayout(actions) + action_grid.setContentsMargins(0, 0, 0, 0) + self.start_button = QPushButton(actions) + self.confirm_button = QPushButton(actions) + self.finish_button = QPushButton(actions) + self.abort_button = QPushButton(actions) + action_grid.addWidget(self.start_button, 0, 0) + action_grid.addWidget(self.confirm_button, 0, 1) + action_grid.addWidget(self.finish_button, 1, 0) + action_grid.addWidget(self.abort_button, 1, 1) + form.addRow(actions) + self.progress_label = QLabel(controls) + self.progress_label.setWordWrap(True) + form.addRow(self.progress_label) + self.result_label = QLabel(controls) + self.result_label.setWordWrap(True) + form.addRow(self.result_label) + body.addWidget(controls, 2) + outer.addLayout(body, 1) + + 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.start_button.clicked.connect(self._start) + self.confirm_button.clicked.connect(self._confirm) + self.finish_button.clicked.connect(self._finish) + self.abort_button.clicked.connect(self._abort) + self._timer = QTimer(self) + self._timer.setInterval(250) + self._timer.timeout.connect(self._poll) + self._timer.start() + self.retranslate() + self._sync() + + def set_flat(self, flat, bundle_path: str) -> None: + self._flat = flat + self._bundle_path = bundle_path + self.flat_view.set_flat(flat) + origin_x, origin_y = uv_origin(flat) + self._origin = (origin_x, origin_y) + self.u_spin.setRange(-origin_x, flat.width - 1 - origin_x) + self.v_spin.setRange(origin_y - flat.height + 1, origin_y) + self._site_changed() + + def _picked(self, u: int, v: int) -> None: + origin_x, origin_y = self._origin + self.u_spin.setValue(u - origin_x) + self.v_spin.setValue(origin_y - v) + self._site_changed() + + def _site_changed(self) -> None: + try: + self._site = site_at( + self._flat, + self.u_spin.value(), + self.v_spin.value(), + origin=self._origin, + ) + except ValueError: + self._site = None + origin = self._origin + self.flat_view.set_marker( + (origin[0] + self.u_spin.value(), origin[1] - self.v_spin.value()) + if origin is not None + else None + ) + self.flat_view.set_selected( + self._site.raster_xy_px if self._site is not None else None + ) + self.position_value.setText( + "—" + if self._site is None + else ", ".join(f"{v:.3f}" for v in self._site.position_sensor_mm) + " mm" + ) + self._sync() + + def _choose_output(self) -> None: + path = QFileDialog.getExistingDirectory( + self, t("calibration.output"), self.output_path.text() + ) + if path: + self.output_path.setText(path) + + def _start(self) -> None: + try: + if self._site is None: + raise ValueError(t("calibration.invalid_site")) + if not self.output_path.text().strip(): + raise ValueError(t("calibration.output_required")) + levels = [float(value.strip()) for value in self.levels.text().split(",")] + source = { + "kind": self.source_panel.source_kind.currentData(), + "value": ( + self.source_panel.video_path.text().strip() + if self.source_panel.source_kind.currentData() == "video" + else self.source_panel.camera_index.value() + ), + } + mode = self.source_panel.mode + if mode is None: + raise RuntimeError(t("calibration.start_input")) + directory = self.trial.start( + root=Path(self.output_path.text().strip()), + site=self._site, + levels=levels, + hold_s=self.hold.value(), + sensor_sn=self.sensor_sn.text(), + bundle_path=self._bundle_path, + source=source, + config_ref=self.source_panel.config_ref.text().strip(), + fps=mode.fps, + ) + self.views.setCurrentWidget(self.source_panel) + self.result_label.setText( + str(directory) + + ( + "\n" + t("calibration.replay_note") + if not self.trial.reference_alignment_possible + else "" + ) + ) + except (OSError, RuntimeError, ValueError) as exc: + self.result_label.setText(t("calibration.error").format(detail=exc)) + self._sync() + + def _confirm(self) -> None: + try: + if not self.reference.text().strip(): + raise ValueError(t("calibration.reference_required")) + self.trial.confirm(float(self.reference.text())) + self.reference.clear() + except (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: + 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: + self.result_label.setText(t("calibration.error").format(detail=exc)) + self._sync() + + def _poll(self) -> None: + previous = self.trial.status + current = self.trial.poll() + if current != previous and current in {"complete", "incomplete"}: + self.result_label.setText( + t("calibration.result").format( + status=t(f"calibration.{current}"), path=self.trial.directory + ) + + ( + "\n" + t("calibration.replay_note") + if not self.trial.reference_alignment_possible + else "" + ) + ) + self._sync() + + 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) + + def _sync(self) -> None: + recording = self.trial.status == "recording" + locked = recording or self.trial.status == "closing" + for widget in ( + self.u_spin, + self.v_spin, + self.levels, + self.hold, + self.sensor_sn, + self.output_path, + self.output_browse, + self.bundle_button, + ): + widget.setEnabled(not locked) + 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) + 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( + branch=t(f"calibration.{step[0]}"), load=step[1] + ) + if recording and step is not None + else t("calibration.no_step") + ) + self.progress_label.setText( + t("calibration.progress").format( + done=self.trial.step_index, + 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 "" + ) + ) + worker = self.source_panel.worker + packet = worker.latest() + self.source_state.setText( + t("calibration.source_state").format( + status=t("source.running") + if worker.is_running + else t("source.stopped"), + frame=packet.frame_id if packet is not None else "—", + dropped=getattr(packet, "capture_dropped", "—") + if packet is not None + else "—", + ) + ) + + def retranslate(self) -> None: + self.sn_label.setText(t("calibration.sensor_sn")) + self.output_label.setText(t("calibration.output")) + self.output_browse.setText(t("source.browse")) + self.views.setTabText(0, t("calibration.site_view")) + self.views.setTabText(1, t("calibration.live_view")) + self.bundle_button.setText(t("finger.load")) + self.coords_label.setText(t("calibration.uv")) + self.position_label.setText(t("calibration.position")) + self.levels_label.setText(t("calibration.levels")) + self.hold_label.setText(t("calibration.hold")) + self.reference_label.setText(t("calibration.reference")) + self.start_button.setText(t("calibration.start")) + self.confirm_button.setText(t("calibration.confirm")) + self.finish_button.setText(t("calibration.finish")) + self.abort_button.setText(t("calibration.abort")) + self._sync() diff --git a/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py b/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py index 6881143..c4cf997 100644 --- a/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py @@ -49,6 +49,7 @@ class FingerSourcePanel(QWidget): self._active = False self._error = "" self._status_key = "source.idle" + self.mode = None layout = QVBoxLayout(self) fields = QGridLayout() @@ -180,7 +181,7 @@ class FingerSourcePanel(QWidget): self.preview.setText(t("source.no_frame")) self.frame_label.clear() try: - self.worker.start(source, config_ref=str(config)) + self.mode = self.worker.start(source, config_ref=str(config)) except Exception as exc: # noqa: BLE001 - show source/config errors to operator self._show_error(str(exc)) return diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index e77a5e9..ce5ab88 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -64,6 +64,7 @@ from .axis_readout import AxisReadoutPanel from .brand_bar import BrandBar from .bus_data_panel import BusDataPanel from .bus_panel import default_bas_dir +from .calibration_panel import CalibrationPanel from .device_setup import DeviceSetupDialog from .finger_source_panel import FingerSourcePanel from .sensor_panel import FingerPanel @@ -295,12 +296,14 @@ class AutoCalMainWindow(QMainWindow): source_kind=str(self.prefs.get("finger_source_kind") or "video"), camera_index=self.prefs.get("finger_camera_index") or 0, ) + self.calibration_panel = CalibrationPanel(self.finger_source_panel, body) + self.calibration_panel.loadBundleRequested.connect(self._on_load_bundle) self.workspace = WorkspaceTabs( self.axis_panel, self.sequence_panel, self.finger_panel, body, - sensor_panel=self.finger_source_panel, + sensor_panel=self.calibration_panel, ) self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body) @@ -413,6 +416,7 @@ class AutoCalMainWindow(QMainWindow): texture_path, texture = self._resolve_texture(bundle) flat = rasterise(bundle, flat_size, texture=texture) self.finger_panel.set_bundle(bundle, flat) + self.calibration_panel.set_flat(flat, str(path)) self.finger_panel.set_texture_state(textured=flat.textured, path=str(texture_path or "")) self.prefs["finger_bundle_path"] = str(path) info = bundle.describe() @@ -1286,6 +1290,7 @@ class AutoCalMainWindow(QMainWindow): self.sequence_panel.retranslate() self.finger_panel.retranslate() self.finger_source_panel.retranslate() + self.calibration_panel.retranslate() self.workspace.retranslate() self.menu_file.setTitle(t("menu.file")) @@ -1381,6 +1386,7 @@ class AutoCalMainWindow(QMainWindow): def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override """Stop the platform, close the controller, then persist and leave.""" + self.calibration_panel.shutdown() self.finger_source_panel.shutdown() logger.info("Closing: stopping axes and shutting the poller down") try: diff --git a/apps/autocal/tests/test_finger_source_panel.py b/apps/autocal/tests/test_finger_source_panel.py index 632bba2..54f9390 100644 --- a/apps/autocal/tests/test_finger_source_panel.py +++ b/apps/autocal/tests/test_finger_source_panel.py @@ -68,7 +68,8 @@ def test_video_controls_use_shared_worker_and_show_processed_frame( def test_sensor_tab_is_live_panel_and_paths_are_saved(make_window, tmp_dir): window = make_window() - assert window.workspace.page("sensor") is window.finger_source_panel + assert window.workspace.page("sensor") is window.calibration_panel + assert window.calibration_panel.source_panel is window.finger_source_panel window.finger_source_panel.video_path.setText(str(tmp_dir / "input.mp4")) window.finger_source_panel.config_ref.setText(str(tmp_dir / "sensor.json")) window.finger_source_panel.source_kind.setCurrentIndex(1) diff --git a/apps/autocal/tests/test_manual_trial.py b/apps/autocal/tests/test_manual_trial.py new file mode 100644 index 0000000..85eaa6a --- /dev/null +++ b/apps/autocal/tests/test_manual_trial.py @@ -0,0 +1,161 @@ +"""A manually confirmed site produces one traceable trial directory.""" + +from __future__ import annotations + +import csv +import json +import time +from types import SimpleNamespace + +import numpy as np +import pytest +from orisys_app_shared.acquisition.models import SdkPacket +from orisys_autocal.trial import ManualTrial, load_path, site_at, uv_origin + + +class Worker: + def __init__(self) -> None: + self.is_running = True + self.sinks = [] + self.packet = SdkPacket( + raw_frame=np.zeros((32, 32, 3), dtype=np.uint8), + processed_frame=np.zeros((32, 32, 3), dtype=np.uint8), + timestamp=time.time(), + fps=30, + process_ms=1, + session_id=2, + frame_id=1, + mono_t=1.0, + capture_mono_ns=time.perf_counter_ns(), + capture_utc_ns=time.time_ns(), + source_frame_id=7, + time_basis="host_camera", + normal_force_raw=1.25, + ) + + def latest(self): + return self.packet + + def add_sink(self, sink): + self.sinks.append(sink) + + def remove_sink(self, sink): + self.sinks.remove(sink) + + def emit(self): + for sink in self.sinks: + sink(self.packet) + + +def test_uv_coordinates_are_centered_and_masked(): + mask = np.full((5, 7), -1, dtype=np.int32) + mask[1:4, 2:5] = 2 + flat = SimpleNamespace( + width=7, + height=5, + face_index=mask, + sample=lambda x, y: ( + (np.array([x, y, 3.0]), np.array([0, 0, 1]), 2) + if 0 <= x < 7 and 0 <= y < 5 and mask[y, x] >= 0 + else None + ), + ) + assert uv_origin(flat) == (3, 3) + site = site_at(flat, -1, 2) + assert site.raster_xy_px == (2, 1) + assert site.position_sensor_mm == (2.0, 1.0, 3.0) + with pytest.raises(ValueError): + site_at(flat, -3, 0) + + +def test_manual_trial_records_video_stamps_and_load_unload_events(tmp_dir): + worker = Worker() + trial = ManualTrial(worker) + bundle = tmp_dir / "bundle" + bundle.mkdir() + (bundle / "mesh.npz").write_bytes(b"mesh") + config = tmp_dir / "sensor.json" + config.write_text("{}", encoding="utf-8") + site = SimpleNamespace( + u_px=0, + v_px=1, + position_sensor_mm=(1.0, 2.0, 3.0), + face_index=2, + raster_xy_px=(3, 2), + uv_origin_raster_xy_px=(3, 3), + ) + assert load_path([5, 10]) == [ + ("load", 5), + ("load", 10), + ("unload", 5), + ("unload", 0), + ] + directory = trial.start( + root=tmp_dir, + site=site, + levels=[5, 10], + hold_s=20, + sensor_sn="F-001", + bundle_path=str(bundle), + source={"kind": "camera", "value": 0}, + config_ref=str(config), + fps=30, + ) + worker.emit() + for value in (5, 10, 5, 0): + trial.confirm(value) + trial.finish() + report = trial.recorder.stop(wait=True) + assert report.ok + assert trial.poll() == "complete" + assert len(list(directory.glob("raw_*.mp4"))) == 1 + with (directory / "frames.csv").open(encoding="utf-8", newline="") as handle: + row = next(csv.DictReader(handle)) + assert row["t_host_mono_ns"] == str(worker.packet.capture_mono_ns) + assert row["source_frame_id"] == "7" + assert row["time_basis"] == "host_camera" + assert row["capture_dropped"] == "0" + with (directory / "events.csv").open(encoding="utf-8", newline="") as handle: + events = list(csv.DictReader(handle)) + reached = [event for event in events if event["kind"] == "step_reached"] + assert len(reached) == 4 + assert [json.loads(event["detail"])["branch"] for event in reached] == [ + "load", + "load", + "unload", + "unload", + ] + with (directory / "session.json").open(encoding="utf-8") as handle: + session = json.load(handle) + assert session["site"]["uv_xy_px"] == [0, 1] + assert session["site"]["mesh_sha256"] + + +def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view( + make_window, +): + from orisys_autocal.sensor.uv_flat import FlatSurface + + window = make_window() + mask = np.full((5, 7), -1, dtype=np.int32) + mask[1:4, 2:5] = 2 + positions = np.zeros((5, 7, 3), dtype=np.float64) + positions[1, 2] = (2, 1, 3) + flat = FlatSurface( + size_hw=(5, 7), + image_rgb=np.zeros((5, 7, 3), dtype=np.uint8), + positions_mm=positions, + normals=np.zeros_like(positions), + face_index=mask, + uv_min=(0, 0), + uv_max=(1, 1), + ) + panel = window.calibration_panel + panel.set_flat(flat, "bundle") + panel._picked(2, 1) + assert (panel.u_spin.value(), panel.v_spin.value()) == (-1, 2) + assert panel._site.raster_xy_px == (2, 1) + assert "2.000" in panel.position_value.text() + panel.views.setCurrentWidget(panel.source_panel) + assert panel._site.raster_xy_px == (2, 1) + window.close() diff --git a/shared/src/orisys_app_shared/record/experiment.py b/shared/src/orisys_app_shared/record/experiment.py index 73e521c..8f3b3d3 100644 --- a/shared/src/orisys_app_shared/record/experiment.py +++ b/shared/src/orisys_app_shared/record/experiment.py @@ -46,7 +46,7 @@ from orisys_app_shared.record.video_writer import VideoSegments logger = logging.getLogger(__name__) -RECORD_FORMAT_VERSION = "1.2" +RECORD_FORMAT_VERSION = "1.3" # Lifecycle states. ``failed`` and ``stopped`` are terminal; a recorder that has # not reached a terminal state must not be reused. @@ -86,6 +86,12 @@ FRAMES_HEADER = ( "segment_frame", "in_video", "dropped", + "t_host_mono_ns", + "t_host_utc_ns", + "source_frame_id", + "time_basis", + "media_time_ms", + "capture_dropped", ) #: ``frame_id`` was added so an operator action can be tied to a frame position. EVENTS_HEADER = ("at", "t_rel_s", "frame_id", "kind", "detail") @@ -1141,6 +1147,12 @@ class _SessionWriter: segment_frame, 1 if segment else 0, 0, + sample.capture_mono_ns or "", + sample.capture_utc_ns or "", + sample.source_frame_id if sample.source_frame_id is not None else "", + sample.time_basis, + format_value(sample.media_time_ms), + sample.capture_dropped, ) ) @@ -1158,7 +1170,7 @@ class _SessionWriter: wall_t or time.time(), timezone.utc ).isoformat(timespec="milliseconds") self._frames[1].writerow( - (frame_id, repr(t_rel), acquired, 0, 0, 0, 1) + (frame_id, repr(t_rel), acquired, 0, 0, 0, 1, "", "", "", "", "", "") ) self.gap_rows += 1 diff --git a/shared/src/orisys_app_shared/record/sample.py b/shared/src/orisys_app_shared/record/sample.py index aa1070f..228619c 100644 --- a/shared/src/orisys_app_shared/record/sample.py +++ b/shared/src/orisys_app_shared/record/sample.py @@ -74,6 +74,12 @@ class RecordSample: fn_n_processed: float = float("nan") fx_n_processed: float = float("nan") fy_n_processed: float = float("nan") + capture_mono_ns: int | None = None + capture_utc_ns: int | None = None + source_frame_id: int | None = None + time_basis: str = "" + media_time_ms: float | None = None + capture_dropped: int = 0 # ------------------------------------------------------------- accessors def raw_triplet(self) -> tuple[float, float, float]: @@ -170,6 +176,14 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample: else str(packet_version) ) axes = calibrated_axes_of(packet) + capture = { + "capture_mono_ns": getattr(packet, "capture_mono_ns", None), + "capture_utc_ns": getattr(packet, "capture_utc_ns", None), + "source_frame_id": getattr(packet, "source_frame_id", None), + "time_basis": str(getattr(packet, "time_basis", "") or ""), + "media_time_ms": getattr(packet, "media_time_ms", None), + "capture_dropped": int(getattr(packet, "capture_dropped", 0) or 0), + } if not valid or stale: if stale and not reason: @@ -183,6 +197,7 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample: invalid_reason=reason, calibrated_axes=axes, calibration_version=version, + **capture, ) raw_available = bool(getattr(packet, "raw_available", True)) @@ -225,6 +240,7 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample: fy_n_processed=newton[2], calibrated_axes=axes, calibration_version=version, + **capture, )