实现 finger 单测点手动试验采集
将精确 UV 选点、实时预览与逐档人工确认接入同一页面,并保存每次试验的视频、事件和帧采集时间。通过离线视频、页面及记录回归测试。
This commit is contained in:
@@ -224,7 +224,7 @@
|
|||||||
"tab_axes": "Axis debug",
|
"tab_axes": "Axis debug",
|
||||||
"tab_sequence": "Test sequence",
|
"tab_sequence": "Test sequence",
|
||||||
"tab_finger": "Finger debug",
|
"tab_finger": "Finger debug",
|
||||||
"tab_sensor": "Sensor",
|
"tab_sensor": "Calibration capture",
|
||||||
"tab_surface": "Surface map",
|
"tab_surface": "Surface map",
|
||||||
"tab_points": "Calibration points",
|
"tab_points": "Calibration points",
|
||||||
"not_built": "Not built yet",
|
"not_built": "Not built yet",
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"idle": "Choose an input source and sensor.json",
|
"idle": "Choose an input source and sensor.json",
|
||||||
"no_frame": "Waiting for processed frames",
|
"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_video": "Choose Finger video",
|
||||||
"choose_config": "Choose sensor.json",
|
"choose_config": "Choose sensor.json",
|
||||||
"video_filter": "Video files (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
"video_filter": "Video files (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
||||||
@@ -262,6 +262,40 @@
|
|||||||
"basis_unknown": "read time (source unverified)",
|
"basis_unknown": "read time (source unverified)",
|
||||||
"frame": "Frame {frame} · source {source_frame} · {basis} {time} · dropped {dropped}"
|
"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": {
|
"finger": {
|
||||||
"title": "Finger calibration-point debug",
|
"title": "Finger calibration-point debug",
|
||||||
"bundle_path": "Export bundle",
|
"bundle_path": "Export bundle",
|
||||||
|
|||||||
@@ -224,7 +224,7 @@
|
|||||||
"tab_axes": "轴调试",
|
"tab_axes": "轴调试",
|
||||||
"tab_sequence": "测试序列",
|
"tab_sequence": "测试序列",
|
||||||
"tab_finger": "手指调试",
|
"tab_finger": "手指调试",
|
||||||
"tab_sensor": "传感器",
|
"tab_sensor": "标定采集",
|
||||||
"tab_surface": "表面分布",
|
"tab_surface": "表面分布",
|
||||||
"tab_points": "标定点",
|
"tab_points": "标定点",
|
||||||
"not_built": "尚未实现",
|
"not_built": "尚未实现",
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
"idle": "请选择输入来源和 sensor.json",
|
"idle": "请选择输入来源和 sensor.json",
|
||||||
"no_frame": "等待处理后的画面",
|
"no_frame": "等待处理后的画面",
|
||||||
"preview_only": "此页用于检查输入画面;标定事件记录尚未接通。",
|
"preview_only": "先在选择测点页确定 U/V,再在此检查实时二维 UV 图像并开始试验记录。",
|
||||||
"choose_video": "选择 Finger 视频",
|
"choose_video": "选择 Finger 视频",
|
||||||
"choose_config": "选择 sensor.json",
|
"choose_config": "选择 sensor.json",
|
||||||
"video_filter": "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
"video_filter": "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
||||||
@@ -262,6 +262,40 @@
|
|||||||
"basis_unknown": "读取时间(来源未确认)",
|
"basis_unknown": "读取时间(来源未确认)",
|
||||||
"frame": "帧 {frame} · 源帧 {source_frame} · {basis} {time} · 丢帧 {dropped}"
|
"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": {
|
"finger": {
|
||||||
"title": "手指标定点调试",
|
"title": "手指标定点调试",
|
||||||
"bundle_path": "导出包",
|
"bundle_path": "导出包",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Autocal 标定操作与数据格式草稿
|
# 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_xy_px`、`raster_xy_px`、`position_sensor_mm` | 当前 U/V 为整数 pixel;网格坐标为 mm |
|
||||||
| UV 定义 | `uv_origin_px`、图像尺寸、轴正方向 | 原点在有效 mask 内底部中间;允许负 Y |
|
| UV 定义 | `uv_origin_raster_xy_px`、图像尺寸、轴正方向 | 当前取最靠近图像中线的有效列底部像素为原点;U 向右、V 向上,允许负 V |
|
||||||
| 几何 | `geometry_id`、mesh/mask/映射文件及哈希 | 固定映射版本,二维与三维值可相互核对 |
|
| 几何 | `geometry_id`、mesh/mask/映射文件及哈希 | 固定映射版本,二维与三维值可相互核对 |
|
||||||
| 姿态 | `T_platform_from_sensor`、`pose_source`、激励方向 | 记录传感器→平台变换,区分实测和指令;normal 不必单独保存 |
|
| 姿态 | `T_platform_from_sensor`、`pose_source`、激励方向 | 记录传感器→平台变换,区分实测和指令;normal 不必单独保存 |
|
||||||
| 关联层级 | `session → site → trial → step → samples` | trial 是完整循环,step 是循环内档位;帧与参考流按时间关联 |
|
| 关联层级 | `session → site → trial → step → samples` | trial 是完整循环,step 是循环内档位;帧与参考流按时间关联 |
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
},
|
},
|
||||||
"config_ref": "config/sensors/finger/sensor.json",
|
"config_ref": "config/sensors/finger/sensor.json",
|
||||||
"status": "incomplete",
|
"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": [{
|
"trials": [{
|
||||||
"trial_id": "trial-001",
|
"trial_id": "trial-001",
|
||||||
"site_id": "site-001",
|
"site_id": "site-001",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Autocal finger 标定实施计划
|
# 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。
|
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 协议或硬件等待。当前计划不启动子代理。
|
主代理负责接口、M3 和集成;一名子代理负责 M1/M2,另一名负责 M4 与合成会话验证。建议总计主代理加两名子代理,约 4–6 个工程工作日并留设备联调时间;单人约 6–9 日。估算不包括设备、FT 协议或硬件等待。当前计划不启动子代理。
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -49,6 +49,7 @@ class FingerSourcePanel(QWidget):
|
|||||||
self._active = False
|
self._active = False
|
||||||
self._error = ""
|
self._error = ""
|
||||||
self._status_key = "source.idle"
|
self._status_key = "source.idle"
|
||||||
|
self.mode = None
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
fields = QGridLayout()
|
fields = QGridLayout()
|
||||||
@@ -180,7 +181,7 @@ class FingerSourcePanel(QWidget):
|
|||||||
self.preview.setText(t("source.no_frame"))
|
self.preview.setText(t("source.no_frame"))
|
||||||
self.frame_label.clear()
|
self.frame_label.clear()
|
||||||
try:
|
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
|
except Exception as exc: # noqa: BLE001 - show source/config errors to operator
|
||||||
self._show_error(str(exc))
|
self._show_error(str(exc))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ from .axis_readout import AxisReadoutPanel
|
|||||||
from .brand_bar import BrandBar
|
from .brand_bar import BrandBar
|
||||||
from .bus_data_panel import BusDataPanel
|
from .bus_data_panel import BusDataPanel
|
||||||
from .bus_panel import default_bas_dir
|
from .bus_panel import default_bas_dir
|
||||||
|
from .calibration_panel import CalibrationPanel
|
||||||
from .device_setup import DeviceSetupDialog
|
from .device_setup import DeviceSetupDialog
|
||||||
from .finger_source_panel import FingerSourcePanel
|
from .finger_source_panel import FingerSourcePanel
|
||||||
from .sensor_panel import FingerPanel
|
from .sensor_panel import FingerPanel
|
||||||
@@ -295,12 +296,14 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
source_kind=str(self.prefs.get("finger_source_kind") or "video"),
|
source_kind=str(self.prefs.get("finger_source_kind") or "video"),
|
||||||
camera_index=self.prefs.get("finger_camera_index") or 0,
|
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.workspace = WorkspaceTabs(
|
||||||
self.axis_panel,
|
self.axis_panel,
|
||||||
self.sequence_panel,
|
self.sequence_panel,
|
||||||
self.finger_panel,
|
self.finger_panel,
|
||||||
body,
|
body,
|
||||||
sensor_panel=self.finger_source_panel,
|
sensor_panel=self.calibration_panel,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
||||||
@@ -413,6 +416,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
texture_path, texture = self._resolve_texture(bundle)
|
texture_path, texture = self._resolve_texture(bundle)
|
||||||
flat = rasterise(bundle, flat_size, texture=texture)
|
flat = rasterise(bundle, flat_size, texture=texture)
|
||||||
self.finger_panel.set_bundle(bundle, flat)
|
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.finger_panel.set_texture_state(textured=flat.textured, path=str(texture_path or ""))
|
||||||
self.prefs["finger_bundle_path"] = str(path)
|
self.prefs["finger_bundle_path"] = str(path)
|
||||||
info = bundle.describe()
|
info = bundle.describe()
|
||||||
@@ -1286,6 +1290,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.sequence_panel.retranslate()
|
self.sequence_panel.retranslate()
|
||||||
self.finger_panel.retranslate()
|
self.finger_panel.retranslate()
|
||||||
self.finger_source_panel.retranslate()
|
self.finger_source_panel.retranslate()
|
||||||
|
self.calibration_panel.retranslate()
|
||||||
self.workspace.retranslate()
|
self.workspace.retranslate()
|
||||||
|
|
||||||
self.menu_file.setTitle(t("menu.file"))
|
self.menu_file.setTitle(t("menu.file"))
|
||||||
@@ -1381,6 +1386,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
|
|
||||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
||||||
"""Stop the platform, close the controller, then persist and leave."""
|
"""Stop the platform, close the controller, then persist and leave."""
|
||||||
|
self.calibration_panel.shutdown()
|
||||||
self.finger_source_panel.shutdown()
|
self.finger_source_panel.shutdown()
|
||||||
logger.info("Closing: stopping axes and shutting the poller down")
|
logger.info("Closing: stopping axes and shutting the poller down")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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):
|
def test_sensor_tab_is_live_panel_and_paths_are_saved(make_window, tmp_dir):
|
||||||
window = make_window()
|
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.video_path.setText(str(tmp_dir / "input.mp4"))
|
||||||
window.finger_source_panel.config_ref.setText(str(tmp_dir / "sensor.json"))
|
window.finger_source_panel.config_ref.setText(str(tmp_dir / "sensor.json"))
|
||||||
window.finger_source_panel.source_kind.setCurrentIndex(1)
|
window.finger_source_panel.source_kind.setCurrentIndex(1)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -46,7 +46,7 @@ from orisys_app_shared.record.video_writer import VideoSegments
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Lifecycle states. ``failed`` and ``stopped`` are terminal; a recorder that has
|
||||||
# not reached a terminal state must not be reused.
|
# not reached a terminal state must not be reused.
|
||||||
@@ -86,6 +86,12 @@ FRAMES_HEADER = (
|
|||||||
"segment_frame",
|
"segment_frame",
|
||||||
"in_video",
|
"in_video",
|
||||||
"dropped",
|
"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.
|
#: ``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")
|
EVENTS_HEADER = ("at", "t_rel_s", "frame_id", "kind", "detail")
|
||||||
@@ -1141,6 +1147,12 @@ class _SessionWriter:
|
|||||||
segment_frame,
|
segment_frame,
|
||||||
1 if segment else 0,
|
1 if segment else 0,
|
||||||
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
|
wall_t or time.time(), timezone.utc
|
||||||
).isoformat(timespec="milliseconds")
|
).isoformat(timespec="milliseconds")
|
||||||
self._frames[1].writerow(
|
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
|
self.gap_rows += 1
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ class RecordSample:
|
|||||||
fn_n_processed: float = float("nan")
|
fn_n_processed: float = float("nan")
|
||||||
fx_n_processed: float = float("nan")
|
fx_n_processed: float = float("nan")
|
||||||
fy_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
|
# ------------------------------------------------------------- accessors
|
||||||
def raw_triplet(self) -> tuple[float, float, float]:
|
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)
|
else str(packet_version)
|
||||||
)
|
)
|
||||||
axes = calibrated_axes_of(packet)
|
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 not valid or stale:
|
||||||
if stale and not reason:
|
if stale and not reason:
|
||||||
@@ -183,6 +197,7 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample:
|
|||||||
invalid_reason=reason,
|
invalid_reason=reason,
|
||||||
calibrated_axes=axes,
|
calibrated_axes=axes,
|
||||||
calibration_version=version,
|
calibration_version=version,
|
||||||
|
**capture,
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_available = bool(getattr(packet, "raw_available", True))
|
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],
|
fy_n_processed=newton[2],
|
||||||
calibrated_axes=axes,
|
calibrated_axes=axes,
|
||||||
calibration_version=version,
|
calibration_version=version,
|
||||||
|
**capture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user