重构 Autocal 采集页:单一队列与控制器驱动的主按钮

选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
This commit is contained in:
ykcheungab
2026-10-18 16:04:32 +08:00
parent 85d0b396e3
commit 06e06126ca
44 changed files with 7769 additions and 2218 deletions
+31 -32
View File
@@ -26,11 +26,20 @@ Replaces the vendor example program it was ported from:
and RTEX parameters. Each family is enabled only when the controller reports
that bus type.
## Capture workflow
The 标定采集 page is one queue, not two panels — a single point is a queue with one
row. `Ready` lists the six facts a run needs and the **primary button follows the
step**, always stating why it is disabled. Alignment is only a request: the move
goes through the guarded pose-move path, and only a completed move unlocks capture.
Recording options (video, codec, lossless frames) are chosen before the session and
written once into `session.json`; an unavailable codec is disabled with its reason,
never silently substituted. [docs/workflow.md](docs/workflow.md) has the detail.
## Run it
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
for the same reason and no longer uses it.)
licence purchase (PyQt5 is GPLv3-or-commercial).
```powershell
# with the environment that already has PySide6 (Anaconda py310, for example)
@@ -50,12 +59,9 @@ resolve to `apps/autocal/` rather than the studio's.
### Controller library
The ZMotion runtime is vendored at `shared/resources/zmotion/x64/` (see the
README beside it for provenance). Point `ORISYS_ZMOTION_DLL_DIR` at another
directory to use a different build — on a motion controller, silently loading
the wrong runtime version is the kind of fault that looks like a working machine
until it moves, so the override is authoritative: if it does not contain
`zauxdll.dll`, the app refuses to connect instead of quietly falling back.
The ZMotion runtime is vendored at `shared/resources/zmotion/x64/`. `ORISYS_ZMOTION_DLL_DIR` overrides it, and the
override is authoritative: a directory without `zauxdll.dll` makes the app refuse
to connect rather than quietly load another build.
One contract in `shared/src/orisys_app_shared/zmotion/client.py` is worth
knowing before touching it: `ZAux_Execute` **must** be given a reply buffer.
@@ -71,8 +77,7 @@ The vendor sequence, with the checks AutoCal's own state machine adds. P1 of
1. **Connect** (`eth` + the controller's IP). On success the app adopts any BAS
program the controller is already running from a previous session instead of
claiming the bus is uninitialised; `BUS_TYPE` is read immediately, and
`Bus_InitStatus` on the first poll.
claiming the bus is uninitialised; `BUS_TYPE` is read immediately.
2. **Download only if the bus type is unknown.** `ECAT.bas` / `RTEX.bas` are
chosen from `config/bas/` (the vendor requires a non-Chinese path) and
written to ROM. A controller that still has the program needs no download.
@@ -97,7 +102,7 @@ The vendor sequence, with the checks AutoCal's own state machine adds. P1 of
| Range | Family | What the operator should do |
|---|---|---|
| `0` | success | — |
| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, make sure nothing else holds the connection, then retry. Frequently transient: a first open on a cold route can fail and succeed on the next attempt, which is why an Ethernet connect makes up to 3 attempts before reporting. |
| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, then retry; a first open on a cold route is often transient, which is why an Ethernet connect makes up to 3 attempts. |
| `30000+` | the auxiliary block `zauxdll2.h` documents | the message names it (`无应答`, `参数错误`, …) |
| anything else | the controller's own code | look it up in ZDevelop's table |
@@ -158,6 +163,10 @@ apps/autocal/
tests/
```
`capture_controller.py` is the capture page's Qt-free step machine (queue, `Ready`,
one primary action, remeasure/skip/stop, poll); `recording_options.py` turns the
video choice into a `RecordConfig` and probes which codecs this machine can write.
`motion/poller.py` is the only place a controller call can originate. It owns
the DLL handle on its own thread, so the vendor library's blocking calls (a ROM
write, a bus scan) never freeze the window and the ctypes handle is never shared
@@ -177,26 +186,18 @@ python -m pytest apps\autocal\tests # offscreen suite
```
Connection and bus initialisation are used once per machine start-up, so they live
in a **modeless device-setup dialog** (`ui/device_setup.py`) rather than the rail:
the rail carries what is read while working — the axis readout and the collapsed
SDO/RTEX monitor. The dialog opens from the status strip's 设备设置 button, from the
connection/bus/init chips (each opens the tab that explains it) or from
文件 → 设备设置, and the strip keeps reporting the live state whether it is open or
shut.
in a **modeless device-setup dialog** (`ui/device_setup.py`), opened from the status
strip, its chips, or 文件 → 设备设置. The rail carries what is read while working:
the axis readout and the collapsed SDO/RTEX monitor.
`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which
`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so
the pane is empty in those shots. It needs the real platform plugin and a visible
window, and it writes either the pane alone or the whole page with the pane
composited in. What the view draws, and what each colour means, is in
`preview_snapshot.py` is the only tool that can photograph the **3D pane** (the
offscreen platform has no OpenGL context, so the pane is empty there). It needs
the real platform plugin and a visible window. See
[docs/finger-preview.md](docs/finger-preview.md).
`layout_mockup.py` is a **wireframe, not the application**: it paints the
proposed single-page layout with the shared theme tokens and imports nothing
from the app, so the layout can be reviewed and marked up without building the
panels. It draws the full page plus previews of the other workspace tabs in one
canvas, and `--page setup` draws the device-setup dialog over the page it opens
from.
`layout_mockup.py` is a **wireframe, not the application**: it paints the proposed
single-page layout with the shared theme tokens and imports nothing from the app,
so the layout can be reviewed without building the panels.
`panel_check.py` builds the real window in both themes at 1440×900 and
1920×1080, asserts that no widget escapes its parent, that motion is
@@ -207,10 +208,8 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules
are skipped rather than failed: a broken environment should not look like a
broken application. `tests/test_binding_purity.py` enforces the binding policy
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
other binding, and the controller interface must stay importable headless. The
studio app enforces the same rules for itself in
`apps/studio/tests/test_binding_purity.py`.
unscoped Qt enumerator, `shared/` may import PySide6 and no other binding, and
the controller interface must stay importable headless.
## Do not ship this app
+164 -34
View File
@@ -230,7 +230,7 @@
"workspace": {
"tab_axes": "Axis debug",
"tab_sequence": "Test sequence",
"tab_finger": "Finger debug",
"tab_finger": "Geometry & mounting",
"tab_sensor": "Calibration capture",
"tab_records": "Calibration records",
"not_built": "Not built yet",
@@ -338,7 +338,6 @@
"remove": "Delete selected",
"up": "Move up",
"down": "Move down",
"import_points": "Import from finger debug",
"align": "Align normal",
"reacquire": "Record selected again",
"source": "Reference source",
@@ -410,7 +409,7 @@
"capture_wait": "recording is not ready; the hold cannot be confirmed yet"
},
"finger": {
"title": "Finger calibration-point debug",
"title": "Geometry & mounting",
"bundle_path": "Export bundle",
"load": "Load bundle…",
"reset_view": "Reset view",
@@ -421,8 +420,7 @@
"flat_title": "Flat image (click to pick)",
"readout_title": "Readouts",
"preview_title": "3D preview",
"points_title": "Picked points",
"select_hint": "Click a point on the flat image to pick a calibration point.",
"select_hint": "Click a point on the flat map to try that point's pose.",
"no_bundle": "No export bundle loaded.",
"no_surface": "Pixel ({x}, {y}) is not on the sensing surface (UV gap).",
"row_sensor_point": "Sensor point (mm)",
@@ -453,14 +451,7 @@
"sensor_xy": "Sensor X / Y",
"sensor_zy": "Sensor Z / yaw",
"setup_hint": "How the sensor actually sits on the stack with the machine at zero: X/Y/Z translation and a yaw about Z. Z is the mounting-plate thickness. The plate on this bench is 8 mm.",
"clear_points": "Clear points",
"remove_selected": "Remove selected",
"move_up": "Move up",
"move_down": "Move down",
"selected_count": "{count} selected",
"table_hint": "Tick a row to select it, double-click a sensor coordinate or normal to edit it. The pose columns are solved from those, so they update themselves.",
"edit_not_a_number": "That cell needs a number; the previous value was kept.",
"point_count": "{count} points",
"residual_value": "{residual}° — {verdict}",
"reachable": "this normal can be aligned with Z",
"unreachable": "this normal cannot be aligned with Z (yaw travel limit)",
@@ -482,28 +473,9 @@
"preview_no_gl": "No OpenGL context: {error}",
"preview_draw_failed": "Drawing failed: {error}",
"preview_solve_failed": "Solve failed: {error}",
"col_x": "x",
"col_y": "y",
"col_z": "z",
"col_nx": "nx",
"col_ny": "ny",
"col_nz": "nz",
"col_X": "X",
"col_Y": "Y",
"col_Z": "Z",
"col_yaw": "yaw",
"col_pitch": "pitch",
"col_residual": "residual(°)",
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
"load_failed": "Load failed: {error}",
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°",
"export_sites": "Export operator site list…",
"export_sites_tip": "Export all points in displayed order, with UV pixels and sensor/platform millimetres. UV origin matches calibration.",
"export_no_points": "Pick UV sites first",
"export_edited_point": "Point {order} was edited in 3D and no longer matches its UV pixel; pick it again before exporting",
"sites_exported": "Operator site list exported: {path}",
"col_u_px": "U (px)",
"col_v_px": "V (px)"
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°"
},
"sequence": {
"title": "Test sequence design",
@@ -671,7 +643,8 @@
"guide_incomplete": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect the stop reason and recorded signal. Incomplete attempts are excluded from fitting.",
"guide_check": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: select a trial and Check hold windows; adjust the time interval if needed.",
"guide_save": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect remaining holds or save current analysis.",
"video": "View recording"
"video": "View recording",
"video_not_recorded": "Video not recorded, per the session settings"
},
"quality": {
"open": "Repeatability and validation…",
@@ -780,6 +753,163 @@
"retry": "Publish summary again",
"done": "Summary published again: {path}",
"reason_hold_not_found": "This hold attempt is not in the record",
"reason_hold_not_complete": "This hold attempt was discarded by a remeasure"
"reason_hold_not_complete": "This hold attempt was discarded by a remeasure",
"copy_failed": "The summary was published, but the readable copy was not written: {detail}. The pointer still names the published summary and the recording is unchanged."
},
"cap": {
"check": {
"bundle": "Calibration bundle",
"frames": "Input frames",
"bus": "Bus and axis mapping",
"output": "Output folder writable",
"sn": "Sensor SN",
"max_force": "Maximum force"
},
"action": {
"start": "Start capture",
"align": "Align this point",
"confirm_target": "At the target — begin the hold",
"confirm_hold": "Hold finished — record this level",
"confirm_liftoff": "Confirm the probe has left the sensor",
"finish": "Finish the session",
"remeasure": "Measure this level again",
"skip": "Skip this point",
"stop": "Stop and keep the data",
"open_session": "Open session folder",
"open_summary": "Open summary",
"copy_path": "Copy path",
"settings": "Change input / bundle…"
},
"block": {
"closing": "Still writing to disk — wait for the close to finish",
"align_while_recording": "Recording: the platform cannot move to align another point",
"skip_live": "This level is already being recorded and cannot be skipped",
"no_directory": "There is no session folder yet",
"no_summary": "No summary has been published yet",
"not_ready": "Some checks are still unmet — see the Ready list above",
"no_sites": "The queue has no points yet",
"no_session": "The session settings are incomplete (output folder, model, SN, …)",
"no_solver": "No pose solver is connected, so the platform cannot be aligned",
"no_pose": "This point has no usable solved pose",
"hold_running": "The hold time has not elapsed",
"remeasure_unavailable": "No level can be measured again right now",
"stop_unavailable": "There is no running session to stop",
"finish_unavailable": "The session is not finished yet",
"skip_unavailable": "This build has no way to skip the current point, so it cannot be skipped",
"busy": "This action is not available in the current step",
"no_bundle": "No calibration bundle (export) is loaded",
"no_uv": "Click a position on the UV map first",
"no_output": "Choose the output directory first",
"no_sn": "Enter the sensor SN first",
"no_max_force": "Enter the maximum force (N) first",
"queue_busy": "The session is running; the point queue cannot be edited",
"bundle_busy": "The session is running; the calibration bundle cannot be changed",
"no_worker": "The input is not running, so recording cannot start",
"bad_levels": "Load levels must be strictly increasing, above zero and within the maximum force",
"bad_reference": "The measured reference force is not a valid number",
"align_running": "An alignment move is still running; wait for it to finish",
"pose_unreachable": "This point cannot be reached under the current mounting and travel, so aligning is disabled"
},
"session": {
"active": "Session: {sites} site(s) · {done}/{total} holds\n{path}",
"none": "No session yet. Align the first point, then press Start capture.",
"settings_hint": "Input source, bundle, session parameters and recording options are in \"Change input / bundle…\"; starting a capture does not switch pages."
},
"queue": {
"title": "Point queue",
"empty": "The queue is empty: click a position on the UV map, set the levels, then press Add current point.",
"add": "Add current point",
"update": "Update selected",
"remove": "Remove selected",
"up": "Move up",
"down": "Move down",
"site_id": "Site id",
"repeats": "Repeats",
"current_uv": "Current UV",
"col_id": "Site",
"col_position": "Sensor position (mm)",
"col_repeats": "Repeats",
"col_reachable": "Reachable",
"col_state": "State",
"reachable": "reachable",
"unreachable": "unreachable",
"state_pending": "pending",
"state_partial": "in progress",
"state_complete": "complete",
"state_skipped": "skipped",
"selected": "Selected: {site}",
"update_short": "Update",
"remove_short": "Remove",
"export": "Export point sheet…",
"export_tip": "Exports U/V, sensor coordinates, platform targets and the mapping identity in queue order; refuses when a coordinate no longer matches the mapping.",
"export_saved": "Point sheet exported: {path}",
"export_no_points": "The queue has no points yet",
"export_no_map": "No UV map is loaded, so the point sheet cannot be exported",
"export_missing_uv": "Point {order} has no UV coordinates, so the point sheet cannot be exported",
"export_no_pose": "Point {order} has no solved pose, so the point sheet cannot be exported",
"export_edited_point": "Point {order}'s sensor coordinates no longer match the current UV map; take the point again before exporting."
},
"card": {
"title": "Current point",
"no_point": "The queue is empty: add a point first.",
"no_progress": "Progress appears once the session starts.",
"next_site": "Next point: {site}"
},
"step": {
"align_point": "1. Align the current point",
"start_session": "2. Start capture",
"confirm_target": "3. Target reached - start the hold",
"confirm_hold": "4. Hold finished - record this step",
"confirm_liftoff": "5. Confirm liftoff, go to the next point",
"finish_session": "6. Finish the session",
"closing": "Writing to disk"
},
"hint": {
"align_point": "Align first: the primary button asks for the move plan; Start capture only appears after the move has been confirmed complete.",
"start_session": "This point is aligned. The primary button opens the session and records the first point step by step.",
"not_ready": "The ✗ checks must pass before starting; hover them to see why.",
"confirm_target": "Load to the current target, enter the measured reference force (or the six-axis components), then press the primary button.",
"confirm_hold": "Holding: {seconds:.1f} s left; do not change the load.",
"confirm_liftoff": "This repeat is recorded. Lift the indenter, confirm it has left the sensor, then press the primary button to go to the next point.",
"finish_session": "Every record is written. Press the primary button to finish the session and publish the summary.",
"closing": "Closing the recording and checking it; nothing else runs until it finishes."
},
"message": {
"aligning": "Alignment requested for {site}. Start capture stays unavailable until the move completes and is confirmed.",
"aligned": "Alignment confirmed: {site}",
"align_failed": "Alignment did not finish: {detail}. The platform is not confirmed in place; align again.",
"started": "Session started: {path}",
"attach_failed": "Could not start recording: {detail}",
"queue_added": "Point added: {site}",
"queue_removed": "Point removed: {site}",
"skipped": "Point skipped: {site}",
"stopped": "Stopped; the data already written is kept.",
"done": "Session finished: {path}"
},
"recording": {
"title": "Recording",
"video": "Record video",
"codec": "Video codec",
"lossless": "Also store lossless frames",
"unavailable": "{codec}: unavailable on this machine, disabled",
"probe_failed": "The codec probe did not run: {detail}",
"off": "Video off, per the session settings",
"estimate": "Estimated {size} ({seconds}); {free} free on disk",
"disk_short": "Less than twice the estimated size is free on disk",
"no_plan": "The queue is empty, so no size can be estimated yet",
"locked": "Recording options are locked once the session has started",
"summary": "Recording: {codec}"
},
"settings": {
"title": "Capture settings",
"input": "Input source",
"bundle": "Calibration bundle (export)",
"bundle_none": "not loaded",
"bundle_load": "Load export bundle…",
"session": "Session parameters",
"simulate": "Simulate (no camera, no platform motion)",
"close": "Close",
"max_required": "Required; the program does not assume a value"
}
}
}
+164 -34
View File
@@ -230,7 +230,7 @@
"workspace": {
"tab_axes": "轴调试",
"tab_sequence": "测试序列",
"tab_finger": "手指调试",
"tab_finger": "几何与安装",
"tab_sensor": "标定采集",
"tab_records": "标定记录",
"not_built": "尚未实现",
@@ -338,7 +338,6 @@
"remove": "删除所选",
"up": "上移",
"down": "下移",
"import_points": "从手指调试导入",
"align": "对齐法向",
"reacquire": "补采所选",
"source": "参考力来源",
@@ -410,7 +409,7 @@
"capture_wait": "录像未就绪,暂时不能确认保持"
},
"finger": {
"title": "手指标定点调试",
"title": "几何与安装",
"bundle_path": "导出包",
"load": "载入导出包…",
"reset_view": "复位视角",
@@ -421,8 +420,7 @@
"flat_title": "展开平面图(点击选点)",
"readout_title": "读数",
"preview_title": "三维预览",
"points_title": "已选标定点",
"select_hint": "在展开图上点击一点以选取标定点。",
"select_hint": "在展开图上点击一点以试算该点的姿态。",
"no_bundle": "尚未载入导出包。",
"no_surface": "像素 ({x}, {y}) 不在传感面上(UV 空隙)。",
"row_sensor_point": "传感器坐标 (mm)",
@@ -453,14 +451,7 @@
"sensor_xy": "传感器 X / Y",
"sensor_zy": "传感器 Z / yaw",
"setup_hint": "传感器在转台零位时相对工作台原点的实际安装位姿:X/Y/Z 平移与绕 Z 的偏摆。Z 为安装板厚度,当前安装台是 8 mm。",
"clear_points": "清空标定点",
"remove_selected": "删除所选",
"move_up": "上移",
"move_down": "下移",
"selected_count": "已选 {count} 点",
"table_hint": "勾选复选框选中该点;双击传感器坐标或法向即可修改。位姿列由这些数值解算得出,会自动更新。",
"edit_not_a_number": "该单元格需要数字,已保留原值。",
"point_count": "共 {count} 点",
"residual_value": "{residual}° — {verdict}",
"reachable": "该点法向可与 Z 轴对齐",
"unreachable": "该点法向无法与 Z 轴对齐(受 yaw 行程限制)",
@@ -482,28 +473,9 @@
"preview_no_gl": "无 OpenGL 上下文:{error}",
"preview_draw_failed": "绘制失败:{error}",
"preview_solve_failed": "求解失败:{error}",
"col_x": "x",
"col_y": "y",
"col_z": "z",
"col_nx": "nx",
"col_ny": "ny",
"col_nz": "nz",
"col_X": "X",
"col_Y": "Y",
"col_Z": "Z",
"col_yaw": "yaw",
"col_pitch": "pitch",
"col_residual": "残差(°)",
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
"load_failed": "载入失败: {error}",
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°",
"export_sites": "导出测点操作表…",
"export_sites_tip": "按当前列表顺序导出全部测点的 U/V 像素、传感器与平台毫米坐标;与标定页共用 UV 原点。",
"export_no_points": "请先在 UV 图上选择测点",
"export_edited_point": "第 {order} 点的三维位置已改动,与原 UV 像素不一致;请重新在图上选点后导出",
"sites_exported": "已导出测点操作表:{path}",
"col_u_px": "U (px)",
"col_v_px": "V (px)"
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°"
},
"sequence": {
"title": "测试序列设计",
@@ -671,7 +643,8 @@
"guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。",
"guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。",
"guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。",
"video": "查看录像"
"video": "查看录像",
"video_not_recorded": "按设置未录视频"
},
"quality": {
"open": "重复性与验证…",
@@ -780,6 +753,163 @@
"retry": "重新导出汇总",
"done": "汇总已重新发布:{path}",
"reason_hold_not_found": "记录中找不到这个保持尝试",
"reason_hold_not_complete": "这个保持尝试已被重测废弃,没有可复核的画面"
"reason_hold_not_complete": "这个保持尝试已被重测废弃,没有可复核的画面",
"copy_failed": "汇总已发布,但可读副本没有写成:{detail}。指针仍指向已发布的汇总,采集结果未改变。"
},
"cap": {
"check": {
"bundle": "标定包",
"frames": "输入出帧",
"bus": "总线与轴映射",
"output": "输出目录可写",
"sn": "传感器 SN",
"max_force": "最大力"
},
"action": {
"start": "开始采集",
"align": "对准当前点",
"confirm_target": "已到目标力,开始保持",
"confirm_hold": "保持完成,记录本档",
"confirm_liftoff": "确认压头已脱离",
"finish": "结束会话",
"remeasure": "重测本档",
"skip": "跳过本点",
"stop": "停止并保留数据",
"open_session": "打开会话文件夹",
"open_summary": "打开汇总",
"copy_path": "复制路径",
"settings": "更改输入/标定包…"
},
"block": {
"closing": "正在写盘,等待收尾完成",
"align_while_recording": "正在录制,不能移动平台去对准其它点",
"skip_live": "这一档已经开始录制,不能跳过",
"no_directory": "还没有会话目录",
"no_summary": "还没有汇总;结束本场标定后自动发布",
"not_ready": "还有未满足的检查项,见上方 Ready 列表",
"no_sites": "队列里还没有测点",
"no_session": "还没有配置会话参数(输出目录、型号、SN 等)",
"no_solver": "没有接上姿态求解器,不能对准",
"no_pose": "这个点还没有解出可用的姿态",
"hold_running": "保持时间还没到",
"remeasure_unavailable": "当前没有可重测的档位",
"stop_unavailable": "没有进行中的会话可停止",
"finish_unavailable": "会话还没有结束,不能点结束",
"skip_unavailable": "程序没有提供跳过当前点的推进接口,本版不能跳过",
"busy": "当前阶段不能执行这个动作",
"no_bundle": "还没有载入标定包(导出包)",
"no_uv": "先在 UV 图上点选位置",
"no_output": "先选择保存目录",
"no_sn": "先填写传感器 SN",
"no_max_force": "先填写最大力 (N)",
"queue_busy": "会话进行中,不能修改测点队列",
"bundle_busy": "会话进行中,不能更换标定包",
"no_worker": "输入没有运行,无法开始录像",
"bad_levels": "加载档位需要严格递增、每档大于 0 且不超过最大力",
"bad_reference": "人工参考力不是有效数字",
"align_running": "对准移动还在进行,请等它完成",
"pose_unreachable": "当前安装或行程下这个点不可达,已禁止对准"
},
"session": {
"active": "本场标定:{sites} 个测点 · 进度 {done}/{total} 档\n{path}",
"none": "尚未建立本场标定;对准第一点后点「开始采集」创建。",
"settings_hint": "输入源、标定包、会话参数与录像选项都在「更改输入/标定包…」里;开始试验不会切换页面。"
},
"queue": {
"title": "测点队列",
"empty": "队列里还没有测点:在 UV 图上点选位置,填好档位后点「添加当前点」。",
"add": "添加当前点",
"update": "更新选中",
"remove": "删除选中",
"up": "上移",
"down": "下移",
"site_id": "测点编号",
"repeats": "重复次数",
"current_uv": "当前 UV",
"col_id": "测点",
"col_position": "传感器位置 (mm)",
"col_repeats": "重复进度",
"col_reachable": "可达性",
"col_state": "状态",
"reachable": "可达",
"unreachable": "不可达",
"state_pending": "待采",
"state_partial": "进行中",
"state_complete": "已完成",
"state_skipped": "已跳过",
"selected": "当前选中:{site}",
"update_short": "更新",
"remove_short": "删除",
"export": "导出测点操作表…",
"export_tip": "按当前队列顺序导出 U/V、传感器坐标、平台目标与映射身份;坐标与映射不符时拒绝导出。",
"export_saved": "已导出操作表:{path}",
"export_no_points": "队列里还没有测点",
"export_no_map": "还没有载入 UV 映射,无法导出操作表",
"export_missing_uv": "第 {order} 点没有 UV 坐标,无法导出操作表",
"export_no_pose": "第 {order} 点还没有解出姿态,无法导出操作表",
"export_edited_point": "第 {order} 点的传感器坐标与当前 UV 映射不符;请重新取点后再导出。"
},
"card": {
"title": "当前点",
"no_point": "队列是空的:先添加测点。",
"no_progress": "开始后显示进度。",
"next_site": "下一测点:{site}"
},
"step": {
"align_point": "① 对准当前点",
"start_session": "② 开始采集",
"confirm_target": "③ 到达目标力,开始保持",
"confirm_hold": "④ 保持完成,记录本档",
"confirm_liftoff": "⑤ 确认离开,换到下一个点",
"finish_session": "⑥ 结束会话",
"closing": "正在写盘"
},
"hint": {
"align_point": "先对准:点主按钮确认移动计划,平台到位并确认后才会出现「开始采集」。",
"start_session": "本点已对准。点主按钮建立本场标定,并从第一个点开始逐档记录。",
"not_ready": "标记 ✗ 的检查项满足后才能开始;鼠标停在标记上可看原因。",
"confirm_target": "按当前档位加载到目标力,填写人工参考力(或六维力分量)后点主按钮。",
"confirm_hold": "保持中:剩余 {seconds:.1f} s;请勿换档。",
"confirm_liftoff": "这一轮记录完成。抬起接触头、确认压头已脱离传感器,再点主按钮换到下一个点。",
"finish_session": "全部记录已写入。点主按钮结束本场标定并发布汇总。",
"closing": "正在写盘并检查录像;收尾完成前不能继续。"
},
"message": {
"aligning": "已发出对准请求:{site}。移动完成并确认后才会允许开始采集。",
"aligned": "已确认对准:{site}",
"align_failed": "对准未完成:{detail}。平台没有确认到位,请重新对准。",
"started": "已开始本场标定:{path}",
"attach_failed": "无法开始录像:{detail}",
"queue_added": "已添加测点 {site}",
"queue_removed": "已删除测点 {site}",
"skipped": "已跳过测点 {site}",
"stopped": "已停止,已写入的数据保留。",
"done": "本场标定已结束:{path}"
},
"recording": {
"title": "录像",
"video": "录制视频",
"codec": "视频编码",
"lossless": "同时保存无损帧",
"unavailable": "{codec}:本机不可用,已禁用",
"probe_failed": "编码探测没有运行:{detail}",
"off": "按设置不录视频",
"estimate": "预计占用 {size}({seconds});磁盘剩余 {free}",
"disk_short": "磁盘剩余不足估算体积的两倍",
"no_plan": "队列为空,暂时无法估算体积",
"locked": "会话建立后录像选项锁定",
"summary": "录像:{codec}"
},
"settings": {
"title": "采集设置",
"input": "输入源",
"bundle": "标定包(导出包)",
"bundle_none": "未载入",
"bundle_load": "载入导出包…",
"session": "会话参数",
"simulate": "模拟运行(不接相机、不移动平台)",
"close": "关闭",
"max_required": "必须填写,程序不预设"
}
}
}
@@ -57,5 +57,10 @@
"settle_window_s": 0.5,
"repeats": 3,
"order": "serpentine"
},
"recording": {
"video": true,
"codec": "mp4v",
"lossless_frames": false
}
}
+11 -11
View File
@@ -15,7 +15,7 @@
| 映射身份 | 保存原点、栅格尺寸和 `mapping_sha256`;哈希覆盖实际位置查找表及有效掩膜 |
| 层级 | `session → site → trial → step → samples`;同点重复及中止重试均创建新 trial |
[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md)。本页不会自动移动平台。
[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md);本页不自动移动平台。
## 操作员逐步流程
@@ -24,7 +24,7 @@
| 1. 准备 | 填型号、SN、保存位置,载入 finger 导出包和配置 | 首次开始试验时建立本场 session;型号、SN、配置和映射在本场固定 |
| 2. 预览 | 选视频或相机编号,启动输入并检查二维 UV 图像 | 两种来源共用 finger worker;输入预览可贯穿多个 trial |
| 3. 选点 | 点击 UV 粗选,再修改 U/V 到精确像素 | 显示传感器三维位置;拒绝掩膜外点 |
| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整路径;如 `0 → 5 → 10 → 20 → 30 → 20 → 10 → 5 → 0 N` |
| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整加载与卸载路径,顺序即记录顺序 |
| 5. 开始 | 无载时点击“开始本次试验” | 新建 trial 并连续录像;先提示初始零载 |
| 6. 保持 | 到目标后输入实际参考力,点击“已到目标力,开始保持” | 当前目标不变,倒计时期间禁止再次确认或完成试验 |
| 7. 换档 | 倒计时结束后按提示手动调整至下一档 | 自动结束本档窗口;换档继续录像,但不沿用上一档恒定参考力 |
@@ -36,19 +36,19 @@
倒计时只结束档位窗口,完成或提前结束才停录像。到时不等于稳定;`stability_verified=0`,仍须复核。初始和最终零载保持时间相同,后续按恢复实测调整。
## 模拟页与操作表
## 操作表导出
模拟页和标定页共用 UV 映射。选点、排序后“导出测点操作表…”得到 UTF-8 CSV;操作员按 U/V 手动复现,不自动执行序列。三维位置与原像素不一致时拒绝导出,须重新选点。
模拟页和采集页共用 UV 映射。在采集页队列点“导出测点操作表…”得到 UTF-8 CSV;操作员按 U/V 手动复现,不自动执行。缺 UV、缺已记录姿态,或三维位置与原像素不一致时拒绝导出,须重新选点。
| 操作表字段 | 含义 |
|---|---|
| `order`、`site_id` | 表内顺序及测点编号;采集会话按首次实测顺序另行分配 site_id,以 U/V 和映射身份核对 |
| `order`、`site_id` | 表内顺序及测点编号;会话另行分配 site_id,以 U/V 与映射身份核对 |
| `u_px`、`v_px` | 与标定页相同原点、方向和单位 |
| `x_sensor_mm`/`y_sensor_mm`/`z_sensor_mm` | 传感器三维位置 |
| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 当前模拟安装设置下的平台目标及角度 |
| `origin_raster_x_px`/`origin_raster_y_px`、栅格宽高、轴方向、`mapping_sha256` | 像素坐标复现所需的映射身份 |
| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 该点已记录姿态给出的平台目标及角度 |
| `origin_raster_x_px`/`origin_raster_y_px`、`raster_width_px`/`raster_height_px`、`u_positive`/`v_positive`、`mapping_sha256` | 像素坐标复现所需的映射身份 |
序列页的 [`.oryseq`](sequence-format.md)仍为平台毫米坐标的几何/运动计划;它与模拟页操作表分别导出,不代表已接通载荷执行。
列定义只有 `ui/site_export.py` 一处。序列页的 [`.oryseq`](sequence-format.md)仍为平台毫米坐标的几何/运动计划;它与操作表分别导出,不代表已接通载荷执行。
## 目录与文件
@@ -73,7 +73,7 @@ finger_F001_<时间>_<标识>/
| 文件 | 内容及写入时机 |
|---|---|
| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态;开始、保持结束、trial 结束和整场关闭时更新 |
| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态、`recording` 块(`video`/`codec`/`lossless_frames`,建立时写一次,旧会话缺块按 `video=true` 读);开始、保持结束、trial 结束和关闭时更新 |
| trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、`capture_status`、`capture_reason`、`capture_report`、steps;采集 session_id 是独立的整数编号 |
| `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 |
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
@@ -82,7 +82,7 @@ finger_F001_<时间>_<标识>/
| `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 |
| 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 |
| `derived/summary_*/`、`summary-latest.json` | 汇总四件文件与最后成功发布的指针;字段见[多点格式](multipoint-data-format.zh.md) |
| `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable |
| `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,非内部参考状态;无视频时在分析结果中标记 unavailable |
| `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 |
| 参数/字段 | 定义 |
@@ -138,7 +138,7 @@ finger_F001_<时间>_<标识>/
采集页显示 FN/FX/FY raw 及最近 60 秒曲线,已标定轴另显示 N;无效帧断开,停止或超过 1 秒无新帧时提示。人工参考力不是实时 FT。
实时 UV 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。显示不改变录像及坐标。
实时 UV 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。
检查保持段曲线、样本数、标准差和漂移;起止以保持开始为零,只能在原段内调整。勾选“已检查稳定”后重算并保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合;确认稳定不代表精度合格。
+29 -2
View File
@@ -1,10 +1,10 @@
# Autocal 标定采集页重构实施计划
状态:2026-10-18 计划,未实施。目标是把“选点 → 对准 → 逐档加载 → 记录”收成一条流程:一个测点队列、一个不依赖 Qt 的状态控制器、一个随步骤变化的主按钮;同时加入录像选项、打开文件夹和平铺汇总。执行分工见[任务分工](capture-workflow-tasks.zh.md)。本计划不改变运动安全规则、不自动加载、不写回 SDK 系数。
状态:2026-10-18 计划,P0–P6 已实施并通过独立验收复核。**仅软件验证**:V10 实机验收未执行,需用户指定设备、运动与载荷范围及停机条件;各项检查的实际口径见文末[实施结果](#实施结果2026-10-18)。目标是把“选点 → 对准 → 逐档加载 → 记录”收成一条流程:一个测点队列、一个不依赖 Qt 的状态控制器、一个随步骤变化的主按钮;同时加入录像选项、打开文件夹和平铺汇总。执行分工见[任务分工](capture-workflow-tasks.zh.md)。本计划不改变运动安全规则、不自动加载、不写回 SDK 系数。
## 基线与问题
调查基线:app `main`、HEAD `7607653`;工作树已有 `motion/service.py`、`record_diagnostics.py` 改动,实施时不得覆盖。结论来自源码、界面截图和 `D:/orisys_ws/data/dd02-gl`,未运行程序或硬件。
调查基线:app `main`、HEAD `7607653`(实施时实际 HEAD 为 `7b5ffa7`,以 `.scratch/autocal-capture-workflow/baseline.md` 为准);工作树已有 `motion/service.py`、`record_diagnostics.py` 改动,实施时不得覆盖。结论来自源码、界面截图和 `D:/orisys_ws/data/dd02-gl`,未运行程序或硬件。
| 位置(相对 `apps/autocal/src/orisys_autocal/`) | 问题 |
|---|---|
@@ -116,3 +116,30 @@
| V10 | 实机验收 | 全部完成后 | 需指定设备、运动与载荷范围、停机条件;代理不执行 |
V1–V9 是软件证据,不代表实机可用;V10 未执行前状态只能写“仅软件验证”。
## 实施结果(2026-10-18)
证据都在 `D:\orisys_ws\orisys-app\.scratch\autocal-capture-workflow\`:
| 阶段 | 结果 | 证据 |
|---|---|---|
| P0 | 基线 572 项测试通过;`panel_check` 2/260 既有失败 | `baseline.md` |
| P1、P2 | 打开文件夹、会话根 `summary/` 副本、录像选项与 `recording` 块 | `a/handoff-patches.zh.md`、`a/logs/` |
| P2b | 逐帧口径三轴均超 5% 门槛;保持窗口口径 FN、FX 基本在门槛内、FY 不在;本机 `cv2` 写不了 H.264 | `d/p2b-codec-equivalence.zh.md` |
| P3 | 控制器:队列、六项 Ready、单一主动作带原因、重测/跳过/停止 | `b/notes.md` |
| P4 | 新采集页只经控制器渲染,`ui/` 中 `campaign.phase` 零命中 | `c/verification.md`、`c/screens/` |
| P5 | 「几何与安装」页去掉测点表,操作表迁到队列;`panel_check` 由 2/260 失败变为 260 全过 | `c/verification-p5.md` |
| P6 | 三份格式/契约文档、`workflow.md`、README | 本目录各文档 |
| 验收 | 无阻断;2 项重要 + 1 项次要,修复后复核全部 PASS | `v/acceptance-report.zh.md` |
当前 `pytest apps/autocal/tests` 668 项全过,`panel_check.py` 260 项全过,被改文档的 `doc_lint` 为 clean。
各检查的实际口径:V2、V4、V5、V7 通过;V3 只在本任务范围内 clean,全仓门禁另有并行外部会话引入的 findings;
V6 不适用(未改 `shared/`);V8 截图已备、待人工确认;V9 报告已交,是否切换默认编码待用户按 D2 决定。
## 验收后修正(2026-10-18,审查 F1、F3、F5)
- 会话结束(完成或停止)后,主按钮“结束会话”把页面带回空闲:队列可编辑、可开始新会话,已记录的文件不变。
- 保持时间到点由控制器 `poll()` 自动结束,参考窗口为计划时长(`t_end - t_start = 保持秒数`),与点击或计时器迟到多久无关。
- 勾选“模拟”后不需要平台、总线、相机、标定包,也不需要先对准;没有标定包时,U、V 直接作为点的 X、Y,位姿标为 `simulated`。有平台时仍须先对准。
- 仅软件验证:控制器与 Campaign 的新增测试已运行;界面测试(含新增的离线模拟用例)在本机因缺 libEGL 未能运行,须在 Windows 上补跑。
+24 -13
View File
@@ -1,6 +1,6 @@
# 标定采集页重构:DeepSeek 代理执行清单
状态:2026-10-18 计划,未实施。阶段定义、入口与出口条件、统一验证见[主计划](capture-workflow-plan.zh.md);本页只规定由谁执行、按什么顺序执行、交付什么。复选框记录实际完成情况。
状态:2026-10-18 计划,W0–W4 已完成(P0–P6 全部实施)。**仅软件验证**:独立验收无阻断,2 项重要 + 1 项次要已修复并复核通过;V10 实机验收未执行。阶段定义、入口与出口条件、统一验证见[主计划](capture-workflow-plan.zh.md);本页只规定由谁执行、按什么顺序执行、交付什么。复选框记录实际完成情况。
## 主代理指令
@@ -45,36 +45,36 @@ W4 V: 独立验收 → 主代理修复 → V 复核修复项
### W0 主代理
- [ ] P0 基线:记录 HEAD、工作树状态和基线测试结果到 `scratch/baseline.md`。
- [ ] 冻结接口,写入 `scratch/interfaces.md`:`readiness()`、`primary_action()`、`perform()`、`secondary_actions()`、`poll()` 的签名、返回结构和动作键清单;`RecordingOptions` 的字段与 `session.json` 中 `recording` 块的结构。
- [x] P0 基线:记录 HEAD、工作树状态和基线测试结果到 `scratch/baseline.md`。
- [x] 冻结接口,写入 `scratch/interfaces.md`:`readiness()`、`primary_action()`、`perform()`、`secondary_actions()`、`poll()` 的签名、返回结构和动作键清单;`RecordingOptions` 的字段与 `session.json` 中 `recording` 块的结构。(实施中按缺口三次修订:1.4.1 对准阶梯、1.8/1.10 队列字段与编辑、1.11 补口。)
### A 数据与文件
交接提示:你负责让用户能找到数据、能控制视频体积。不得改变原始目录结构、不得删除或移动已有录像;summary 副本只是方便查看,指针仍是唯一权威。
- [ ] P1:`reveal` 提取与三个按钮(按钮的布局由 C 在 P4 完成;A 在 P1 先接到现有会话行),`summary/` 平铺副本及失败处理。测试:`test_summary_export.py` 增加副本一致性、副本失败两项,新增 `test_reveal.py`。
- [ ] P2:录像选项、工厂函数、`recording` 块、`video_expected` 判定、编码探测、体积估算。测试:`test_multipoint_recording.py` 增加关视频完成、编码不可用两项;`test_summary.py` 增加关视频行 eligible、旧会话不变两项;`test_preferences_isolation.py` 覆盖新的偏好键。
- [x] P1:`reveal` 提取与三个按钮(按钮的布局由 C 在 P4 完成;A 在 P1 先接到现有会话行),`summary/` 平铺副本及失败处理。测试:`test_summary_export.py` 增加副本一致性、副本失败两项,新增 `test_reveal.py`。
- [x] P2:录像选项、工厂函数、`recording` 块、`video_expected` 判定、编码探测、体积估算。测试:`test_multipoint_recording.py` 增加关视频完成、编码不可用两项;`test_summary.py` 增加关视频行 eligible、旧会话不变两项;`test_preferences_isolation.py` 覆盖新的偏好键。(体积估算与探测界面由 C 在 P4 实现。)
### B 控制器
交接提示:你负责把流程判断从界面中移出。控制器不得发出运动命令、不得导入 Qt;对准只发出请求。每个禁用的动作必须带原因,原因文字通过 locale 键给出。
- [ ] P3:实现控制器及表驱动测试;用同一组输入比较“直接驱动 `Campaign`”与“经由控制器”写出的文件结构。
- [x] P3:实现控制器及表驱动测试;用同一组输入比较“直接驱动 `Campaign`”与“经由控制器”写出的文件结构。
### C 界面
交接提示:你负责重排界面,依据是设计稿与主计划 P4、P5。界面只渲染控制器的输出;不得在 `ui/` 中判断 `campaign.phase`。实时图与 UV 图必须能同时看到。
- [ ] P4:新布局、更改输入对话框、队列表模型、当前点卡片、消息行;在模拟模式下用界面测试走完两点;截图存 scratch。
- [ ] P5:Finger debug 改名并瘦身,测点表迁移,安装偏置变化后重新求解。
- [x] P4:新布局、更改输入对话框、队列表模型、当前点卡片、消息行;在模拟模式下用界面测试走完两点;截图存 scratch。
- [x] P5:Finger debug 改名并瘦身,测点表迁移,安装偏置变化后重新求解。
### D 编码验证
- [ ] P2b:按主计划完成对比并写报告,内容包括输入文件、编码参数、逐帧差值统计和窗口均值差占 1 N 阶跃的比例。不给结论性推荐,由用户决定。
- [x] P2b:按主计划完成对比并写报告,内容包括输入文件、编码参数、逐帧差值统计和窗口均值差占 1 N 阶跃的比例。不给结论性推荐,由用户决定。
### V 验收
- [ ] 用新进程跑完主计划 V1–V8;按严重度列出发现,每条附复现步骤;不修改代码。
- [x] 用新进程跑完主计划 V1–V8;按严重度列出发现,每条附复现步骤;不修改代码。
## 子代理交付格式
@@ -89,6 +89,17 @@ W4 V: 独立验收 → 主代理修复 → V 复核修复项
## 主代理最终交接
- [ ] 所有改动保持未暂存;每个改动过的仓库各写 `.scratch/autocal-capture-workflow/commit-message.txt`,按 AGENTS 第 7 条给出单行的 `git add` 和 `git commit -F` 命令。
- [ ] 列出需要排除的既有改动。
- [ ] 状态写为“仅软件验证,待实机验收”;V10 实机验收需要用户指定设备、运动与载荷范围以及停机条件。
- [x] 所有改动保持未暂存;每个改动过的仓库各写 `.scratch/autocal-capture-workflow/commit-message.txt`,按 AGENTS 第 7 条给出单行的 `git add` 和 `git commit -F` 命令。(本任务只改 `orisys-app`。)
- [x] 列出需要排除的既有改动。
- [x] 状态写为“仅软件验证,待实机验收”;V10 实机验收需要用户指定设备、运动与载荷范围以及停机条件。
## 需要排除的既有改动(不属于本任务)
`orisys-app` 工作树里这些改动在任务开始前就存在,提交时必须排除:`apps/autocal/src/orisys_autocal/motion/service.py`、
`apps/autocal/src/orisys_autocal/record_diagnostics.py`;未跟踪的 `LZ.*.zip`、`LZ.LigMaster V1.7.0/`、
`LZ.Devpack_L826(六维力非标设备包)(1)/`、`docs/test-execution-plan.zh.md`。
任务运行期间还有并行会话改了工作区其他部分,同样不属于本任务:`apps/studio/**` 与
`shared/src/orisys_app_shared/theme/qss.py`(另一个 Studio 评审会话)、工作区根的 `docs/README.md` 与
`docs/inventory.json`、`orisys-sdk` 的 `HEAD`(`f7f4682` → `ff7614a`,只改 `calibration.py`)。因此全仓
`lint_gate` 的 `status: FINDINGS` 来自这些外部改动,本任务范围内的文件是 `status: clean`。
@@ -23,6 +23,7 @@ sites/<site_id>/repeat-00N/
| 文件 | 内容 |
|---|---|
| 根 `session.json` | `format_version=autocal-multipoint-1.0`、session_id、型号/SN、`coordinate_frame`、`force_to_tool_rotation`、`normal_projection`、力范围、`sync`、sites、trials、`completion`、`status`/`reason`;`platform_motion` 与 `automatic_force_control` 均为 false |
| 根 `session.json` 的 `recording` 块 | `video`、`codec`、`lossless_frames`;建立会话时写入并锁定,后续 repeat 不改写;缺该块的旧会话按 `video=true` 读取 |
| site 记录 | `site_id`、`position`/`position_frame`、`normal_sensor`、`pose`、`uv_px`、`levels_n`、`hold_s`、`repeats`;`pose` 为空或 `in_range=false` 的点只保存、不下发 |
| repeat `session.json` | `trial_id`、site、`pose`、`uv_px`、`steps[{step_index, branch, target_load_n}]`、`sync`、`images`、`status`;录像收尾后另有 `capture_status`、`capture_reason`、`capture_report`(共享记录的统计与问题清单) |
| `reference.csv` | 每个实际开始的保持窗口一行:`step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、site 坐标与坐标系、`repeat_index`/`repeat_count`、`hold_complete`、`reference_source`、`fx_N`/`fy_N`/`fz_N`、力到工具的旋转、`nx`/`ny`/`nz`、`pose_*`、`normal_aligned_to` |
@@ -80,6 +81,8 @@ unknown,不把 `images.captured=true` 当成录像已验证。
`summary-latest.json`(相对路径、`summary_id`、输入与文件哈希)。采集终态自动发布候选汇总;人工
复核保存后再发布已复核汇总;失败保留旧指针并可重试,采集结果不变。
指针替换成功后,同一份四件文件镜像到会话根 `summary/`:先写临时目录再替换目录;副本失败只在界面/日志报告,指针仍指向 `derived/` 中的已发布版本,采集结果不变。
两张表都带 `schema_version`、`summary_id` 与来源文件:
| 表 | 行单位 | 关键字段 |
@@ -34,6 +34,11 @@
summary.html
baseline_load_data.csv
sensitivity_values.csv
summary/ # 最近一次发布的可读副本(四件文件),非权威
summary.json
summary.html
baseline_load_data.csv
sensitivity_values.csv
summary-latest.json # 最后成功发布的相对路径指针
```
@@ -41,6 +46,8 @@
导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针(取消由调用方传入,界面暂未提供取消按钮)。原始文件、历史 review 和已发布 summary 均不覆盖。
指针替换后镜像同一份四件文件到根 `summary/`(先写临时目录再替换);副本失败只报告,不回滚指针、不改采集结果。
## 录像身份、时间和生命周期
逻辑身份使用 `(calibration_session_id, trial_id, capture_session_id, frame_id)`;保持窗口再加 `step_index`。共享 recorder 的整数 session_id 不等于标定 session_id。同试验来源重启不得静默复用 frame_id;中止并新建 trial。重复身份、行数不符或缺关联必须报错/排除,不能退回按行号拼接。
+7 -1
View File
@@ -4,8 +4,14 @@
图中原有模块归属的源码引用固定在 `19426c3c4a648ca7f4d3785e2e11b1bf124a8e31`。本次五项修复尚未提交:写线程失败时中止、候选与确认结果分别汇总、按所选区间回放、允许已关闭失败记录诊断,以及取消全部解码图像的累积缓存。当前实现以[工作树快照清单](diagrams/recording-20261017-110144/snapshot.json)及其同目录补丁为依据,不将工作树字节冒充 HEAD 源码。
每 trial 连续保存零载、换档、加载和卸载画面,MP4 为有损输入录像。保持窗口依靠主机采集时间与帧身份关联,不能用容器 FPS 推算实验同步。操作员手动确认参考载荷;该流程不自动加载,也不证明 FT 物理同步。
每 trial 连续保存零载、换档、加载和卸载画面,MP4 为有损输入录像。保持窗口依靠主机采集时间与帧身份关联,不能用容器 FPS 推算实验同步。操作员手动确认参考载荷;该流程不自动加载,也不证明 FT 物理同步。界面上的实时曲线与实时 UV 图只显示已有数据,不改变录像与坐标。
原始文件继续使用[单次](calibration-data-format.zh.md)与[多点](multipoint-data-format.zh.md)目录。保持段不拆成新视频,复核与汇总另存版本;字段及计算方法见[汇总契约](recording-summary-contract.zh.md)。
## 录像选项与关闭视频
录像选项(`video`、`codec`、`lossless_frames`)在建立会话前选定,写入根 `session.json` 的 `recording` 块并锁定;会话建立后再改不生效。可用编码在启动时用 3 帧探测,写不出来的编码在界面上禁用并写明原因,绝不静默回退到别的编码。本机 `cv2` 目前只能写 `mp4v`,H.264 需要另定编码器来源(未验证,见 `.scratch/autocal-capture-workflow/d/p2b-codec-equivalence.zh.md`)。
关闭视频时 `capture_verdict` 跳过视频类判定,只保留数据类判定(帧与力的写入、来源会话未变等),因此关视频的 trial 仍可 complete、汇总行仍 eligible;复核界面按 `recording` 块显示「按设置未录视频」。缺该块的旧会话按 `video=true` 读取,结果与以前一致。体积按码率与计划时长估算,与磁盘剩余一并显示,不足两倍估算时警告。
验证:五个回归用例在修复前失败;修复后的相关 45 项测试和 Autocal 全量 572 项测试通过。Archify 的 showcase 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。
+18 -1
View File
@@ -236,7 +236,8 @@ measured time per point.
**Gate:** the operator has seen the path and a dry run has completed without a
guard violation.
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
**The single-point dry run is built**, in the geometry & mounting screen (几何与安装,
which no longer carries a point table): 执行动作 sends the
selected point's *solved pose* to the platform — the stages together with the table
X/Y, and the press axis only once its zero is calibrated (设备设置 → 压轴); until then
it is held, so the move cannot reach the sensor by accident. The order follows the
@@ -282,6 +283,22 @@ data stays valid.
**Existing code:** the motion layer has the guards and the poller; the step
machine, the contact detection and the acquisition binding are new.
**As built today** (manual, through the capture page): the operator keeps one
queue of points — a single point is a queue with one row. `Ready` must be all
green before the first point. The page shows one primary button whose label is
the next step (align → start → at the target → hold finished → confirm liftoff →
finish) and states the reason whenever it is disabled. Alignment only requests a
guarded pose move, and a capture may not start until that move has completed, so
a repeat can never be recorded on a pose nobody confirmed. Remeasure, skip and
stop are the secondary actions. What the button says is derived from the campaign
state by `capture_controller.py`, never by the widgets.
Changing the mounting offset, the tool XY or the travel limits re-solves every
queued pose: while the queue is still editable the new solution is stored, and a
point the stage can no longer reach is marked and refused for alignment. The
exported operation table comes from the same queue (queue → 导出测点操作表…), so
the table and the run cannot describe different points.
---
## P8 — Store raw
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from .recording_options import RecordingOptions
def write_json(path: Path, payload: dict) -> None:
"""Replace a manifest atomically, keeping the previous version on failure."""
@@ -31,11 +33,15 @@ class CalibrationSession:
identity.append(value)
self.directory = root / f"{'_'.join(identity)}_{stamp}_{uuid4().hex[:12]}"
self.identity = self.identity_of(metadata)
#: The video choice belongs to the session, not to each trial: it is
#: written once here and never rewritten by a later ``add_trial``.
self.recording = RecordingOptions.from_block(metadata.get("recording"))
self.document = {
"format_version": "autocal-session-1.0",
"session_id": self.directory.name,
**self.identity,
"config_snapshot": metadata["config_snapshot"],
"recording": self.recording.as_block(),
"started_at": datetime.now(timezone.utc).isoformat(),
"status": "in_progress",
"sites": [],
@@ -0,0 +1,773 @@
"""The capture page's workflow controller.
The controller owns the operator's next step. It wraps one
:class:`~orisys_autocal.multipoint.Campaign` (the point queue) and turns the
campaign's state into the single primary button the page shows, plus the three
secondary actions. It is deliberately headless — no Qt and no motion service — so
the whole decision can be exercised without a window, a camera or a controller.
Aligning a point is only ever *requested*: the request is published on
:attr:`CaptureController.pending_alignment` and the window plans the move. The
controller cannot command motion even by accident.
The queue is edited through the controller too: adding, updating, removing and
reordering a point, and storing the reference rotation, all go through its
methods, and :meth:`model` returns every field the page renders. That keeps the
page from reading ``campaign.sites`` or writing a ``SiteSpec``.
The public contract is frozen in
``.scratch/autocal-capture-workflow/interfaces.md`` section 1. Two facts the
controller does not invent for itself:
* the arguments :meth:`Campaign.start` needs (output root, sensor model and SN,
coordinate frame, simulate flag) are supplied by :attr:`start_arguments`,
because they are the window's current state, not the campaign's;
* whether a point must be aligned first is answered by ``alignment_required``. The
default is yes; the page says no only for a simulated session, which commands no
axis and so needs no platform, bus or surface solver;
* the maximum force is the controller's own ``max_force_n`` and is the only
source of the sixth readiness check. Before a session exists the campaign has
no maximum force, and after it starts the value has already been validated.
The "this point is aligned" flag is what separates :meth:`align_point` from the
other primary actions. Only the window may set it, through
:meth:`mark_aligned`, after the requested move has actually finished: a request
can be refused, cancelled or fail, and starting a session on the old pose would
record the wrong geometry. The flag is cleared when a *different* point comes
into focus (the start of a session and each point change), never after
``campaign.reacquire()``: the platform is already on that point and recording it
again does not need another move.
"""
from __future__ import annotations
import math
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, NamedTuple
from .multipoint import Campaign
if TYPE_CHECKING: # pragma: no cover - typing only, keeps the motion package out
from .motion.kinematics import PlatformStack
#: Readiness rows in the frozen order. The label keys are the page's, not this
#: module's: a locale translation of a reason is never stored in the controller.
READINESS_ITEMS: tuple[tuple[str, str], ...] = (
("calibration_bundle", "cap.check.bundle"),
("input_frames", "cap.check.frames"),
("bus_ready", "cap.check.bus"),
("output_writable", "cap.check.output"),
("sensor_sn", "cap.check.sn"),
("max_force", "cap.check.max_force"),
)
READINESS_KEYS: tuple[str, ...] = tuple(key for key, _label in READINESS_ITEMS)
_ACTION_LABELS = {
"align_point": "cap.action.align",
"start_session": "cap.action.start",
"confirm_target": "cap.action.confirm_target",
"confirm_hold": "cap.action.confirm_hold",
"confirm_liftoff": "cap.action.confirm_liftoff",
"finish_session": "cap.action.finish",
"closing": "cap.block.closing",
"remeasure": "cap.action.remeasure",
"skip": "cap.action.skip",
"stop": "cap.action.stop",
}
class ActionBlocked(RuntimeError):
"""An action exists but cannot run yet.
``reason`` is a locale key (``cap.block.*``), never translated text; the page
owns the translation. ``label_key`` names the action that was refused.
"""
def __init__(self, reason: str, label_key: str = "") -> None:
super().__init__(reason)
self.reason = reason
self.label_key = label_key
@dataclass(frozen=True)
class ReadinessCheck:
"""One external fact the capture page needs before it may start."""
key: str
ok: bool
label_key: str
detail: str = ""
class PrimaryAction(NamedTuple):
key: str
label_key: str
enabled: bool
blocked_reason: str
@dataclass(frozen=True)
class AlignmentRequest:
"""The five-axis targets for one point, for the window's move planner."""
site_id: str
targets: dict[str, float]
@dataclass(frozen=True)
class SiteRow:
"""One row of the point queue, as the page renders it.
Everything the queue table and the point editor may show is copied into the
row, so the page never reads ``campaign.sites`` or a ``SiteSpec`` field.
``pose`` is a snapshot: a display or comparison value, not a handle the page
may write through.
"""
site_id: str
position: dict[str, float]
repeats_planned: int
repeats_complete: int
complete: bool
reachable: bool
levels: tuple[float, ...]
hold_s: float
uv_px: tuple[float, float] | None
normal: tuple[float, float, float] | None
pose: dict | None
position_frame: str
skipped: bool
@dataclass(frozen=True)
class CaptureModel:
"""Everything the page may read about the campaign, in one snapshot."""
sites: list[SiteRow]
current_index: int
progress: dict
class CaptureController:
"""Walk one campaign through the operator's steps without touching Qt or motion."""
def __init__(
self,
campaign: Campaign,
*,
solver: PlatformStack | None = None,
readiness: Callable[[], list[ReadinessCheck]] | None = None,
max_force_n: float | None = None,
start_arguments: Callable[[], dict] | None = None,
alignment_required: Callable[[], bool] | None = None,
) -> None:
self.campaign = campaign
self.solver = solver
self._readiness = readiness
self._max_force_n = None if max_force_n is None else float(max_force_n)
self._start_arguments = start_arguments
self._alignment_probe = alignment_required
self._pending_alignment: AlignmentRequest | None = None
self._aligned: set[str] = set()
self._skipped: set[str] = set()
self._focus_site = ""
# ------------------------------------------------------------------ readiness
@property
def ready(self) -> bool:
return all(check.ok for check in self.readiness())
def readiness(self) -> list[ReadinessCheck]:
"""All six checks, in the frozen order, including the ones that failed."""
probe = self._probe_checks()
result: list[ReadinessCheck] = []
for key, label in READINESS_ITEMS:
if key == "max_force":
result.append(self._max_force_check(key, label))
else:
result.append(probe[key])
return result
def _max_force_check(self, key: str, label: str) -> ReadinessCheck:
value = self._max_force_n
ok = value is not None and math.isfinite(value) and value > 0
return ReadinessCheck(key, ok, label, "" if ok else "max_force_n is not set")
def _probe_checks(self) -> dict[str, ReadinessCheck]:
"""Map the injected probe's answer onto the six frozen keys.
A probe that raises or omits a key must not take the page down: the
missing rows are reported as *not* ready with their cause, because an
unreported fact is not a satisfied one.
"""
if self._readiness is None:
return {
key: ReadinessCheck(key, True, label) for key, label in READINESS_ITEMS
}
try:
supplied = list(self._readiness())
except Exception as exc: # noqa: BLE001 - a probe is external and must not crash the page
detail = str(exc) or type(exc).__name__
return {
key: ReadinessCheck(key, False, label, detail)
for key, label in READINESS_ITEMS
}
reported = {
str(getattr(check, "key", "")): check
for check in supplied
if getattr(check, "key", "")
}
rows: dict[str, ReadinessCheck] = {}
for key, label in READINESS_ITEMS:
check = reported.get(key)
if check is None:
rows[key] = ReadinessCheck(
key, False, label, "the readiness probe did not report this check"
)
else:
rows[key] = ReadinessCheck(
key, bool(check.ok), label, str(getattr(check, "detail", "") or "")
)
return rows
# ------------------------------------------------------------------ primary action
def primary_action(self) -> PrimaryAction:
"""The one button the page shows now, with a reason whenever it is disabled."""
self._sync_alignment_focus()
campaign = self.campaign
phase = campaign.phase
if self._closing():
return PrimaryAction(
"closing", "cap.block.closing", False, "cap.block.closing"
)
if phase == "idle":
if not campaign.sites:
return self._start_action()
# A session that moves nothing has no pose to reach first.
if not self._alignment_required() or self._aligned_in_focus():
return self._start_action()
return self._align_action()
if phase == "liftoff":
pending = campaign.pending_alignment_site
if (
pending
and self._alignment_required()
and not self._is_aligned(pending)
):
action = self._align_action()
if action.enabled:
return action
return PrimaryAction(
"confirm_liftoff", "cap.action.confirm_liftoff", True, ""
)
if phase == "load":
return PrimaryAction(
"confirm_target", "cap.action.confirm_target", True, ""
)
if phase == "hold":
if campaign.remaining_s() > 0:
return PrimaryAction(
"confirm_hold",
"cap.action.confirm_hold",
False,
"cap.block.hold_running",
)
return PrimaryAction("confirm_hold", "cap.action.confirm_hold", True, "")
if phase in {"done", "aborted"}:
return PrimaryAction("finish_session", "cap.action.finish", True, "")
# An unknown phase is never actionable; say the repeat is being written
# rather than inventing a button that would do nothing.
return PrimaryAction("closing", "cap.block.closing", False, "cap.block.busy")
def _alignment_required(self) -> bool:
"""Whether a point must be aligned before it is recorded.
A simulated session commands no axis, so the page says alignment is not
needed and the platform, the bus and the surface solver can all be absent.
Without that answer (no probe, or one that fails) alignment is required: the
stricter reading is the safe one.
"""
if self._alignment_probe is None:
return True
try:
return bool(self._alignment_probe())
except Exception: # noqa: BLE001 - the page's own state must not break the decision
return True
def _start_action(self) -> PrimaryAction:
campaign = self.campaign
if not campaign.sites:
return self._disabled("start_session", "cap.block.no_sites")
if self._start_arguments is None:
return self._disabled("start_session", "cap.block.no_session")
if not self.ready:
return self._disabled("start_session", "cap.block.not_ready")
return PrimaryAction("start_session", "cap.action.start", True, "")
def _align_action(self) -> PrimaryAction:
if self._recording():
return self._disabled("align_point", "cap.block.align_while_recording")
if self.solver is None:
return self._disabled("align_point", "cap.block.no_solver")
site_id = self._focus_site
if not site_id or not self._pose_usable(site_id):
return self._disabled("align_point", "cap.block.no_pose")
return PrimaryAction("align_point", "cap.action.align", True, "")
@staticmethod
def _disabled(key: str, reason: str) -> PrimaryAction:
return PrimaryAction(key, _ACTION_LABELS[key], False, reason)
# ------------------------------------------------------------------ secondary actions
def secondary_actions(self) -> list[PrimaryAction]:
"""Remeasure, skip and stop, in that frozen order."""
self._sync_alignment_focus()
return [self._remeasure_action(), self._skip_action(), self._stop_action()]
def _remeasure_action(self) -> PrimaryAction:
enabled = self.campaign.can_remeasure_step()
return PrimaryAction(
"remeasure",
"cap.action.remeasure",
enabled,
"" if enabled else "cap.block.remeasure_unavailable",
)
def _skip_action(self) -> PrimaryAction:
if not hasattr(self.campaign, "skip_current_point"):
return self._disabled("skip", "cap.block.skip_unavailable")
if self._recording():
return self._disabled("skip", "cap.block.skip_live")
if self.campaign.phase not in {"load", "hold"}:
return self._disabled("skip", "cap.block.busy")
return PrimaryAction("skip", "cap.action.skip", True, "")
def _stop_action(self) -> PrimaryAction:
running = self.campaign.is_active or self.campaign.busy
return PrimaryAction(
"stop",
"cap.action.stop",
running,
"" if running else "cap.block.stop_unavailable",
)
# ------------------------------------------------------------------ perform
def perform(self, key: str, **inputs) -> None:
"""Run one offered action, or explain in a locale key why it cannot run."""
# A recording repeat must never be interrupted by a move, and a caller can
# ask for it directly instead of through the button. This guard outranks
# the "not the current action" check, so the refusal names the real cause.
if key == "align_point" and self._recording():
raise ActionBlocked("cap.block.align_while_recording", "cap.action.align")
self._sync_alignment_focus()
primary = self.primary_action()
secondary = {action.key: action for action in self.secondary_actions()}
if key == primary.key:
action = primary
elif key in secondary:
action = secondary[key]
else:
raise ValueError(f"{key!r} is not an offered capture action")
if not action.enabled:
raise ActionBlocked(action.blocked_reason, action.label_key)
if inputs and key not in {"confirm_target", "stop"}:
raise ValueError(f"{key} takes no inputs")
if key == "start_session":
self._perform_start_session()
elif key == "align_point":
self._perform_align_point()
elif key == "confirm_target":
self._perform_confirm_target(inputs)
elif key == "confirm_hold":
self.campaign.complete_hold()
elif key == "confirm_liftoff":
self.campaign.confirm_liftoff()
elif key == "finish_session":
self._perform_finish_session()
elif key == "remeasure":
self.campaign.remeasure_step()
elif key == "skip":
self._perform_skip()
elif key == "stop":
self._perform_stop(inputs)
else: # pragma: no cover - "closing" and unknown keys never reach here
raise ValueError(f"{key!r} is not an executable capture action")
def _perform_finish_session(self) -> None:
"""Close the finished session out so the queue is editable again.
``flush_close`` is not what ends a session: it only waits for a writer that
is still closing, and ``primary_action`` never offers this key while one is.
What the operator needs is the way back to the idle phase, with the next
session possible and the finished one's files left exactly as they are.
"""
self.campaign.end_session()
# The platform stayed where the last point left it, and the queue may be
# edited next: nothing is aligned until a move says so.
self._aligned.clear()
self._pending_alignment = None
def _perform_start_session(self) -> None:
arguments = self._start_arguments
if arguments is None: # pragma: no cover - the action is disabled without it
raise ActionBlocked("cap.block.no_session", "cap.action.start")
self._pending_alignment = None
self.campaign.start(**arguments())
# A new session measures every point again: skip marks belong to the
# session that recorded them.
self._skipped.clear()
def _perform_align_point(self) -> None:
"""Publish the move request. It does not mean the platform has moved."""
site_id = self._focus_site
try:
targets = dict(self.campaign.command_targets(site_id))
except ValueError as exc:
raise ActionBlocked("cap.block.no_pose", "cap.action.align") from exc
self.pending_alignment = AlignmentRequest(site_id=site_id, targets=targets)
def _perform_confirm_target(self, inputs: dict) -> None:
force_n = inputs.pop("force_n", None)
wrench = inputs.pop("wrench", None)
rotation = inputs.pop("rotation", None)
if inputs:
raise ValueError("confirm_target accepts force_n or wrench, nothing else")
if (force_n is None) == (wrench is None):
raise ValueError("confirm_target needs exactly one of force_n or wrench")
if force_n is not None:
if rotation is not None:
raise ValueError("rotation only applies to a six-axis reference")
self.campaign.confirm_reference(float(force_n))
return
values = tuple(float(value) for value in wrench)
if len(values) != 3:
raise ValueError("wrench needs three components")
if rotation is not None:
# ``Campaign.start`` cleared the rotation; the only moment the operator
# enters it is this confirmation, so set it right before the reading.
self.campaign.set_force_rotation([float(value) for value in rotation])
self.campaign.confirm_wrench(values[0], values[1], values[2])
def _perform_skip(self) -> None:
method = getattr(self.campaign, "skip_current_point", None)
if method is None: # pragma: no cover - the action is disabled without it
raise ActionBlocked("cap.block.skip_unavailable", "cap.action.skip")
site_id = self._focus_site
method()
if site_id:
# The queue still lists the point; the row says it was skipped so the
# operator can see it was not recorded rather than guess from a count.
self._skipped.add(site_id)
def _perform_stop(self, inputs: dict) -> None:
"""Stop the session, with an optional free-text reason for the record.
``session.json``'s ``reason`` is free text, not a locale key: the stop
button keeps the default phrase, while the window's shutdown path passes
its own so an application close stays distinguishable from an operator
stop in the data.
"""
reason = inputs.pop("reason", None)
if inputs:
raise ValueError("stop accepts a reason only")
if reason is None:
text = "operator stopped"
else:
text = str(reason).strip()
if not text:
raise ValueError("a stop reason must not be blank")
self.campaign.stop(text)
# ------------------------------------------------------------------ alignment result
def mark_aligned(self, site_id: str | None = None) -> None:
"""The window confirmed the requested move finished.
Only this call sets the "this point is aligned" flag. A request on its own
must not, because the move can be refused, cancelled, or fail half way —
and starting a session there would record the old pose. ``site_id=None``
names the point currently in focus.
"""
target = self._focus_site_id() if site_id is None else str(site_id)
if not target:
raise ValueError("there is no point in focus to mark as aligned")
self._aligned.add(target)
def invalidate_alignment(self) -> None:
"""Forget every "this point is aligned" flag.
The flag records that the platform stood on a point's pose at some moment.
Anything that moves it elsewhere (a jog, a home, a move from another page),
or changes what a pose means (a new surface, a changed mounting), makes
that statement false, and starting on it would record the wrong geometry.
Never raises: it runs on the window's motion paths.
"""
self._aligned.clear()
def mark_alignment_failed(self, site_id: str | None = None) -> None:
"""A move was refused, cancelled or failed, so the point stays unaligned.
The page still shows ``align_point`` for a retry. This never raises: it
runs on an error path where the page has its own message to show.
"""
target = self._focus_site_id() if site_id is None else str(site_id)
if target:
self._aligned.discard(target)
# ------------------------------------------------------------------ queue editing
def add_point(
self,
site_id: str,
x: float,
y: float,
z: float,
normal: tuple[float, float, float],
pose: dict,
levels: list[float],
hold_s: float,
repeats: int,
*,
uv_px: tuple[float, float] | None = None,
) -> None:
"""Add a solved point to the queue. Idle only; never moves the platform."""
self._require_queue_editable()
self.campaign.add_solved_site(
site_id,
x,
y,
z,
normal,
pose,
levels,
hold_s,
repeats,
max_force_n=self._require_max_force(),
uv_px=uv_px,
)
self._skipped.discard(site_id)
self._aligned.discard(site_id)
def update_point(
self,
site_id: str,
x: float,
y: float,
z: float,
levels: list[float],
hold_s: float,
repeats: int,
*,
new_id: str | None = None,
) -> None:
"""Replace a point's geometry or plan. Idle only.
The stored pose and normal are kept by ``Campaign.update_site`` unless the
page re-solves and stores them; a changed point therefore loses its "this
point is aligned" flag, because that move was aimed at the old geometry.
"""
self._require_queue_editable()
self.campaign.update_site(
site_id,
x,
y,
z,
levels,
hold_s,
repeats,
max_force_n=self._require_max_force(),
new_id=new_id,
)
for target in {site_id, new_id or site_id}:
self._skipped.discard(target)
self._aligned.discard(target)
def remove_point(self, site_id: str) -> None:
"""Remove one point from the queue. Idle only."""
self._require_queue_editable()
self.campaign.remove_site(site_id)
self._skipped.discard(site_id)
self._aligned.discard(site_id)
def move_point(self, site_id: str, delta: int) -> None:
"""Move one point in the queue order. Idle only; positions are unchanged."""
self._require_queue_editable()
self.campaign.move_site(site_id, delta)
def set_force_rotation(self, values: Sequence[float]) -> None:
"""Store the force-sensor-to-tool rotation used by six-axis references."""
self._require_queue_editable()
self.campaign.set_force_rotation(list(values))
def store_solved_pose(self, site_id: str, pose: dict) -> None:
"""Store a freshly solved pose for a point. Idle only.
Re-solving is queue data, so it belongs behind the controller like the
other edits. The point loses its "this point is aligned" flag: a move made
for the old pose is not a move to this one.
"""
self._require_queue_editable()
self.campaign.store_solved_pose(site_id, pose)
self._aligned.discard(site_id)
def set_max_force(self, value: float | None) -> None:
"""Update the maximum force the readiness check and queue edits use."""
self._max_force_n = None if value is None else float(value)
def _require_queue_editable(self) -> None:
if self.campaign.phase != "idle":
raise ActionBlocked("cap.block.queue_busy")
def _require_max_force(self) -> float:
if self._max_force_n is None:
raise ActionBlocked("cap.block.no_max_force")
return self._max_force_n
# ------------------------------------------------------------------ poll and views
def poll(self) -> dict:
"""Advance closing evidence and report what the page should draw.
``campaign.poll()`` is what finalises a repeat's recording; omitting it
would leave the page waiting on a close that never finishes.
"""
self.campaign.poll()
# The planned hold time is the operator's plan, not a button to wait for: the
# window ends when the time does. A failure here (a writer that died during
# the hold, say) reaches the page like any other poll error.
self.campaign.auto_complete_hold()
self._sync_alignment_focus()
return {
"phase": self.campaign.phase,
"capture_state": self.campaign.capture_state,
"capture_reason": self.campaign.capture_reason,
"remaining_s": float(self.campaign.remaining_s()),
"pending_alignment_site": self.campaign.pending_alignment_site,
}
def model(self) -> CaptureModel:
"""The queue and the campaign's own progress, read-only.
One row carries every field the queue table and the point editor show, so
the page never touches ``campaign.sites``. ``pose`` and ``position`` are
copies: the page may compare them, not write through them.
"""
self._sync_alignment_focus()
completion = {row["site_id"]: row for row in self.campaign.completion()}
rows = []
for site in self.campaign.sites:
counts = completion.get(site.site_id) or {}
rows.append(
SiteRow(
site_id=site.site_id,
position=site.position(),
repeats_planned=int(site.repeats),
repeats_complete=int(counts.get("repeats_complete", 0)),
complete=bool(counts.get("complete", False)),
reachable=self._pose_usable(site.site_id),
levels=tuple(float(value) for value in site.levels_n),
hold_s=float(site.hold_s),
uv_px=(
None
if site.uv_px is None
else (float(site.uv_px[0]), float(site.uv_px[1]))
),
normal=(
None
if site.normal is None
else tuple(float(value) for value in site.normal)
),
pose=None if site.pose is None else dict(site.pose),
position_frame=str(site.position_frame or ""),
skipped=site.site_id in self._skipped,
)
)
return CaptureModel(
sites=rows,
current_index=self._current_index(),
progress=self.campaign.progress(),
)
@property
def pending_alignment(self) -> AlignmentRequest | None:
"""The move the page still has to plan. The page clears it after taking it."""
return self._pending_alignment
@pending_alignment.setter
def pending_alignment(self, value: AlignmentRequest | None) -> None:
self._pending_alignment = value
# ------------------------------------------------------------------ state helpers
def _closing(self) -> bool:
return (
self.campaign.phase == "closing" or self.campaign.capture_state == "closing"
)
def _recording(self) -> bool:
return self.campaign.capture_state == "recording" and self.campaign.phase in {
"load",
"hold",
}
def _is_aligned(self, site_id: str) -> bool:
return bool(site_id) and site_id in self._aligned
def _aligned_in_focus(self) -> bool:
return self._is_aligned(self._focus_site)
def _pose_usable(self, site_id: str) -> bool:
"""Whether the stored pose may be commanded. Read-only; never solves."""
try:
self.campaign.command_targets(site_id)
except (KeyError, ValueError):
return False
return True
def _sync_alignment_focus(self) -> str:
"""Track the point in focus; a *different* point is not aligned yet.
An empty focus (no session, or a finished one) never clears a flag, so the
point a ``campaign.reacquire()`` comes back to keeps its alignment.
"""
focus = self._focus_site_id()
if focus and self._focus_site and focus != self._focus_site:
self._aligned.discard(focus)
self._focus_site = focus
return focus
def _focus_site_id(self) -> str:
campaign = self.campaign
if not campaign.sites:
return ""
phase = campaign.phase
if phase == "idle":
return campaign.sites[0].site_id
if phase == "liftoff":
return campaign.pending_alignment_site
if phase in {"done", "aborted"}:
return ""
return str(campaign.progress().get("site_id") or "")
def _current_index(self) -> int:
"""Zero-based row the page should highlight; ``-1`` when there is none.
During ``liftoff`` the campaign has already advanced, so this is the point
the operator is about to align.
"""
sites = self.campaign.sites
if not sites:
return -1
phase = self.campaign.phase
if phase == "idle":
return 0
if phase in {"done", "aborted"}:
return -1
index = int(self.campaign.progress().get("site_index") or 0) - 1
return index if 0 <= index < len(sites) else -1
@@ -156,6 +156,7 @@ def capture_verdict(
timeout_s: float = CLOSING_TIMEOUT_S,
probe_video: bool = True,
source_stopped: bool = False,
video_expected: bool = True,
) -> CaptureVerdict:
"""Decide whether the closed recording is evidence, and say why not.
@@ -163,6 +164,13 @@ def capture_verdict(
keep polling instead of blocking its event loop. Both recording paths pass
their own abort reason: the operator's reason is more useful than a generic
token and is what the existing single-trial contract already records.
``video_expected=False`` is the operator's recorded choice not to keep a
video. The video-specific judgements (a missing frame, an unexpected segment
count, dropped video frames, an undecodable file) are then skipped, because
there is nothing to find; the data rows must still have been written and
dropped data still keeps the evidence incomplete. The default keeps every
existing caller's meaning: a video was expected.
"""
if recorder is None:
# Nothing was recorded, so nothing is evidence. A simulation and a
@@ -208,15 +216,16 @@ def capture_verdict(
return verdict(INCOMPLETE, reason or ABORTED)
if stats.frames <= 0:
return verdict(INCOMPLETE, ZERO_FRAMES)
if stats.video_frames <= 0:
return verdict(INCOMPLETE, VIDEO_MISSING)
if stats.segments != 1:
return verdict(INCOMPLETE, SEGMENT_COUNT)
if video_expected:
if stats.video_frames <= 0:
return verdict(INCOMPLETE, VIDEO_MISSING)
if stats.segments != 1:
return verdict(INCOMPLETE, SEGMENT_COUNT)
if stats.dropped_data:
return verdict(INCOMPLETE, DROPPED_DATA)
if stats.dropped_video:
if video_expected and stats.dropped_video:
return verdict(INCOMPLETE, DROPPED_VIDEO)
if probe_video:
if video_expected and probe_video:
ok, detail = probe_segments(report.directory)
if not ok:
payload = dict(base["report"] or {})
+134 -4
View File
@@ -18,10 +18,13 @@ from __future__ import annotations
import csv
import json
import logging
import math
import re
import shutil
import statistics
import time
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path
@@ -38,6 +41,11 @@ from .capture_guard import (
capture_verdict,
)
from .frames_index import read_frame_index, unique_frames
from .recording_options import (
RecordingOptions,
make_record_config,
require_codec_available,
)
from .sensor.frames import PLATFORM_AXES, TOOL_AXIS
SENSOR_MODEL = "finger"
@@ -408,6 +416,9 @@ class Campaign:
self.sensor_model = ""
self.sensor_sn = ""
self.simulate = False
#: The recording choices this session was established with (plan P2).
#: Written to the root manifest once; every repeat reuses them.
self.recording = RecordingOptions()
self.reason = ""
self._document: dict | None = None
self._site_index = 0
@@ -614,6 +625,8 @@ class Campaign:
max_force_n: float,
simulate: bool,
min_force_n: float = 0.0,
recording: RecordingOptions | None = None,
codec_availability: Mapping[str, bool] | None = None,
) -> Path:
if self.is_active or self.capture_state == "closing" or self.phase == "closing":
raise RuntimeError("a multi-point acquisition is already running")
@@ -633,12 +646,19 @@ class Campaign:
raise ValueError("maximum force must be a positive number you enter")
for site in self.sites:
load_steps(list(site.levels_n), force, min_force_n=lower)
# Refuse an encoder this machine cannot open before anything is written:
# the session block must describe the recording that would actually run.
options = RecordingOptions() if recording is None else recording
if not isinstance(options, RecordingOptions):
raise TypeError("recording must be a RecordingOptions instance")
require_codec_available(options, codec_availability)
self.coordinate_frame = coordinate_frame.strip()
self.max_force_n = force
self.min_force_n = lower
self.sensor_model = sensor_model.strip()
self.sensor_sn = sensor_sn.strip()
self.simulate = bool(simulate)
self.recording = options
self.reason = ""
self._single_site = False
self._finished_step_pending = False
@@ -685,6 +705,7 @@ class Campaign:
"automatic_force_control": False,
"platform_motion": False,
"simulate": self.simulate,
"recording": self.recording.as_block(),
"sync": SYNC,
"sites": [self._site_record(site) for site in self.sites],
"trials": [],
@@ -786,6 +807,34 @@ class Campaign:
self._finished_step_pending = False
self._begin_close(complete=True)
def auto_complete_hold(self) -> bool:
"""Close the hold once its planned time has run out; ``True`` if it closed.
The window ends at the planned time (``start + hold``), not at whenever the
page happened to notice: the reference window is the plan, so the recorded
length must not depend on how late a timer tick or a click was.
"""
if self.phase != "hold" or self._active is None or self._hold_started_ns is None:
return False
if self.remaining_s() > 0:
return False
self.complete_hold(now_ns=self._hold_started_ns + int(self._hold_s * 1e9))
return True
def end_session(self) -> None:
"""Hand a finished or stopped session back to the idle phase.
The recorded evidence is untouched: the directory, the session document and
the per-point counts stay readable, and only the phase changes so the queue
can be edited and the next session started. A repeat that is still being
written is refused, because it is not part of the evidence yet.
"""
if self.phase not in {"done", "aborted"}:
raise RuntimeError("only a finished or stopped session can be closed out")
if self.capture_state == "closing":
raise RuntimeError("the last repeat is still being written")
self.phase = "idle"
def confirm_liftoff(self) -> None:
if self.phase != "liftoff":
raise RuntimeError("liftoff confirmation is only required between points")
@@ -911,6 +960,81 @@ class Campaign:
self._open_repeat()
self.phase = "load"
def skip_current_point(self) -> None:
"""Leave the open point unrecorded and move on to the next one.
A repeat directory and its trial record exist from the moment the repeat
opens, so skipping removes that unfinished record instead of inventing a
trial status the format does not define. The point stays in ``sites`` and
in the session document, so a later ``reacquire`` can still record it.
"""
if self.phase not in {"load", "hold"} or self.capture_state == "closing":
raise RuntimeError("no point is open to skip")
if self._recorder is not None:
# The capture writer owns its directory; stop the recording first.
raise RuntimeError("stop the recording before skipping the point")
site = self.sites[self._site_index]
directory, entry = self._trial_dir, self._trial_entry
# The files must be closed before the folder can be moved (Windows refuses
# otherwise), but nothing about the campaign changes until the folder is
# out of the way: a refusal leaves the repeat exactly as it was.
self._close_repeat_files()
completed = 0
reference = None if directory is None else directory / "reference.csv"
if reference is not None and reference.is_file():
with reference.open(encoding="utf-8-sig", newline="") as handle:
completed = sum(
1 for row in csv.DictReader(handle) if row.get("hold_complete") == "1"
)
aside = None
if directory is not None and directory.exists():
# One rename is all-or-nothing; a recursive delete that fails half way
# would leave a reference file with its rows gone.
aside = self.directory / f".skipped-{directory.parent.name}-{directory.name}"
while aside.exists():
aside = aside.with_name(f"{aside.name}-{uuid4().hex[:6]}")
try:
directory.replace(aside)
except OSError as exc:
self._trial_dir, self._trial_entry = directory, entry
self._reopen_repeat_files(directory)
raise RuntimeError(
f"the unfinished repeat folder could not be set aside ({exc}); "
"close anything that has it open and try again"
) from exc
self._active = None
self._pending = None
self._hold_started_ns = None
self._finished_step_pending = False
self._completed_holds = max(0, self._completed_holds - completed)
if entry is not None:
self._document["trials"].remove(entry)
if aside is not None:
try:
shutil.rmtree(aside)
except OSError as exc:
# The skip itself has happened; a leftover hidden folder at the
# session root cannot collide with a repeat directory.
logging.getLogger(__name__).warning(
"Skipped repeat left at %s: %s", aside, exc
)
self._event(
"site_skipped",
{"site_id": site.site_id, "repeat_index": self._repeat_index + 1},
)
self._site_index += 1
self._repeat_index = 0
if self._site_index < len(self.sites):
self.phase = "liftoff"
self._event(
"liftoff_required",
{"next_site_id": self.sites[self._site_index].site_id},
)
self._save()
return
self._finish_session(self._session_status())
self.phase = "done"
def completion(self) -> list[dict]:
counts: dict[str, int] = {}
if self._document is not None:
@@ -1212,6 +1336,7 @@ class Campaign:
guard=self._closing_guard,
elapsed_s=elapsed,
source_stopped=self._source_stopped,
video_expected=self.recording.video,
)
if verdict.status == PENDING:
self.capture_state = "closing"
@@ -1422,6 +1547,13 @@ class Campaign:
return "partial"
return "incomplete"
def _reopen_repeat_files(self, directory: Path) -> None:
"""Reattach the open repeat's writers after a skip that did not happen."""
self._reopen_reference()
handle = (directory / "events.csv").open("a", newline="", encoding="utf-8")
self._events_file = handle
self._events = csv.writer(handle)
def _reopen_events(self) -> None:
if self._session_events is not None:
return
@@ -1470,11 +1602,9 @@ class Campaign:
raise RuntimeError("start the camera and wait for a frame before live capture")
if self._recorder is not None:
raise RuntimeError("a capture is already running for this repeat")
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record import ExperimentRecorder
recorder = ExperimentRecorder(
RecordConfig(fps=self._fps, segment_seconds=24 * 3600.0, preserve_all_frames=True)
)
recorder = ExperimentRecorder(make_record_config(self.recording, self._fps))
recorder.start(
root=self._trial_dir,
session_id=int(packet.session_id),
@@ -20,6 +20,11 @@ from .motion.service import (
clamp_bas_task,
)
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
from .recording_options import (
DEFAULT_CODEC,
DEFAULT_LOSSLESS_FRAMES,
DEFAULT_VIDEO,
)
#: Connection modes, in the order the combo offers them.
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
@@ -176,6 +181,20 @@ def _default_axis_params() -> dict[str, dict[str, float | int]]:
}
def _default_recording() -> dict[str, Any]:
"""Plan P2: what a capture records.
The bench default stays ``mp4v`` until the P2b equivalence report is accepted
(decision D2); video is captured by default because it is the evidence the
review and the video page read.
"""
return {
"video": DEFAULT_VIDEO,
"codec": DEFAULT_CODEC,
"lossless_frames": DEFAULT_LOSSLESS_FRAMES,
}
def default_preferences() -> dict[str, Any]:
"""AutoCal's default preference document."""
return {
@@ -227,6 +246,8 @@ def default_preferences() -> dict[str, Any]:
"sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters.
"sequence_profile": _default_sequence_profile(),
#: P2: video on/off, codec and lossless frames for a new capture session.
"recording": _default_recording(),
#: The press axis's mechanical zero and sign, so a solved contact height
#: can become a controller target. Uncalibrated means the press axis is
#: held rather than driven.
@@ -277,6 +298,7 @@ _MERGED_BLOCKS = (
"safety_limits",
"sensor_setup",
"sequence_profile",
"recording",
)
@@ -0,0 +1,195 @@
"""What a recording captures, and how those settings reach the shared recorder.
The options are a small, explicit block rather than loose arguments for two
reasons. They are written into the session manifest once, so a later reader can
tell whether a missing video was a failure or the operator's choice. And they are
checked against this machine's encoders **before** recording starts: a codec that
cannot be opened is refused, never silently replaced by another one.
Defaults are the confirmed bench decision (plan D2): video on, ``mp4v``, lossless
frames off. ``mp4v`` measured far more bitrate than H.264, but switching the
default is a separate decision that needs the equivalence report, so this module
keeps the current default until then.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
#: Default recording settings. These constants are the single definition; the
#: preference schema re-exports them instead of spelling the values again.
DEFAULT_VIDEO = True
DEFAULT_CODEC = "mp4v"
DEFAULT_LOSSLESS_FRAMES = False
#: Codecs the startup probe writes a test clip for, in the order the UI offers them.
CODEC_CANDIDATES: tuple[str, ...] = ("mp4v", "avc1", "H264")
#: Probe clip shape: small and short, but a real encode and a real decode.
PROBE_DIRECTORY = "codec_probe"
PROBE_FRAMES = 3
PROBE_WIDTH = 64
PROBE_HEIGHT = 48
PROBE_FPS = 10.0
#: One continuous recording per trial, as the shared recorder already writes it.
TRIAL_SEGMENT_SECONDS = 24 * 3600.0
@dataclass(frozen=True)
class RecordingOptions:
"""The recording settings one session was established with."""
video: bool = DEFAULT_VIDEO
codec: str = DEFAULT_CODEC
lossless_frames: bool = DEFAULT_LOSSLESS_FRAMES
def as_block(self) -> dict[str, object]:
"""The ``recording`` block frozen for ``session.json``."""
return {
"video": bool(self.video),
"codec": self.codec,
"lossless_frames": bool(self.lossless_frames),
}
@classmethod
def from_block(cls, block: object | None) -> "RecordingOptions":
"""Read a stored block; a missing or malformed value keeps its default.
A session recorded before the block existed has no answer to "was the
video expected?". Its recorder always wrote video, so the absent block
means video was expected, which is the default here.
"""
if not isinstance(block, dict):
return cls()
video = block.get("video")
codec = block.get("codec")
lossless = block.get("lossless_frames")
return cls(
video=video if isinstance(video, bool) else DEFAULT_VIDEO,
codec=codec.strip() if isinstance(codec, str) and codec.strip() else DEFAULT_CODEC,
lossless_frames=(
lossless if isinstance(lossless, bool) else DEFAULT_LOSSLESS_FRAMES
),
)
def make_record_config(options: RecordingOptions, fps: float):
"""The shared recorder's config for one trial under these options.
The import stays inside the function so this module (and the preference
schema that reads its defaults) does not pull in the recorder's NumPy and
OpenCV dependencies at start-up.
"""
from orisys_app_shared.record import RecordConfig
return RecordConfig(
fps=float(fps),
segment_seconds=TRIAL_SEGMENT_SECONDS,
preserve_all_frames=True,
video=bool(options.video),
codec=str(options.codec),
lossless_frames=bool(options.lossless_frames),
)
def default_options(prefs: Mapping[str, Any] | None) -> RecordingOptions:
"""The recording settings stored under ``prefs["recording"]``, defaults filled.
Every key falls back independently, so a block saved by an older build (one
that did not have the key yet) is not read as an absent recording choice.
"""
block = prefs.get("recording") if isinstance(prefs, Mapping) else None
return RecordingOptions.from_block(block)
def probe_codecs(scratch: Path) -> dict[str, bool]:
"""Encode a few frames with each candidate codec and report which ones work.
The probe writes real clips into ``scratch`` and reads one frame back, so a
writer that opens but produces an empty file is not called available. Probe
files are removed afterwards; the caller keeps only the result. Nothing is
substituted when a codec fails: the caller disables it and says so.
"""
import cv2
import numpy as np
directory = Path(scratch) / PROBE_DIRECTORY
directory.mkdir(parents=True, exist_ok=True)
frame = np.zeros((PROBE_HEIGHT, PROBE_WIDTH, 3), dtype=np.uint8)
frame[:, :, 1] = 128
availability: dict[str, bool] = {}
for codec in CODEC_CANDIDATES:
target = directory / f"{codec}.mp4"
available = False
try:
writer = cv2.VideoWriter(
str(target),
cv2.VideoWriter_fourcc(*codec),
PROBE_FPS,
(PROBE_WIDTH, PROBE_HEIGHT),
)
try:
if writer.isOpened():
for _ in range(PROBE_FRAMES):
writer.write(frame)
finally:
writer.release()
available = _probe_reads_back(target)
except Exception: # noqa: BLE001 - an encoder failure is a result, not a crash
available = False
finally:
try:
target.unlink()
except OSError:
pass
availability[codec] = available
try:
directory.rmdir()
except OSError:
pass
return availability
def _probe_reads_back(path: Path) -> bool:
"""A written probe is only available when an encoded frame can be decoded."""
import cv2
if not path.is_file() or path.stat().st_size <= 0:
return False
capture = cv2.VideoCapture(str(path))
try:
return bool(capture.isOpened() and capture.read()[0])
finally:
capture.release()
def codec_is_available(options: RecordingOptions, availability: Mapping[str, bool] | None) -> bool:
"""Whether these options can be recorded given a probe result.
``None`` means nothing was probed (a headless caller that is not the UI), and
the answer is left to the recorder rather than guessing.
"""
if not options.video:
return True
if availability is None:
return True
return availability.get(options.codec) is True
def require_codec_available(
options: RecordingOptions, availability: Mapping[str, bool] | None
) -> None:
"""Refuse to start with an encoder this machine cannot open.
There is deliberately no fallback to another codec: a recording made under a
silently different codec is not the recording the session block will claim.
"""
if codec_is_available(options, availability):
return
raise ValueError(
f"the selected video codec is not available on this machine: {options.codec}"
)
@@ -15,6 +15,7 @@ import numpy as np
from .calibration_session import write_json
from .frames_index import read_frame_index, unique_frames
from .recording_options import RecordingOptions
def _rows(path: Path) -> list[dict]:
@@ -133,15 +134,22 @@ def _sha256(path: Path) -> str:
return digest.hexdigest()
def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
def _capture_source(
directory: Path, root: Path, inputs: dict, *, video_expected: bool = True
) -> dict:
"""Read the frame source recorded beside one repeat, or report why it is unusable.
Image-derived force samples only exist where the shared recorder ran: simulation and
a detached camera leave no frames, and the gap has to stay visible as a reason rather
than become an empty window that reads like a measurement.
``video_expected=False`` is a session recorded without video on purpose: the frames
were still acquired (a row carries its host time), only none was written to a video.
Frames are then accepted regardless of video membership and no video file is read.
A session with no recording block keeps the default, which requires the video.
"""
source = {
"recorded": False, "reason": "no_capture_directory",
"recorded": False, "reason": "no_capture_directory", "video_expected": video_expected,
"directory": str(directory.relative_to(root)) if directory.is_relative_to(root) else str(directory),
"capture_session_id": None, "frames": 0, "in_video": 0, "time_bases": [],
"segments": [], "samples": [],
@@ -166,22 +174,26 @@ def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
# left out instead of being paired with an order the files cannot prove.
source["duplicate_frame_ids"] = len(duplicated)
for frame in frame_rows:
if frame.dropped or frame.t_host_mono_ns is None or frame.in_video is False:
if frame.dropped or frame.t_host_mono_ns is None:
continue
if frame.frame_id in duplicated:
continue
segment = frame.segment
video = directory / f"raw_{segment:04d}.mp4"
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
source["reason"] = "missing_video_segment"
return source
if video_expected:
if frame.in_video is False:
continue
segment = frame.segment
video = directory / f"raw_{segment:04d}.mp4"
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
source["reason"] = "missing_video_segment"
return source
segments.add(segment)
in_video += 1
frames[frame.frame_id] = frame.t_host_mono_ns
bases.add(frame.time_basis)
in_video += 1
if segment:
segments.add(segment)
if not frames:
source["reason"] = "no_recorded_video_frames"
source["reason"] = (
"no_recorded_video_frames" if video_expected else "no_recorded_frames"
)
return source
for segment in segments:
video = directory / f"raw_{segment:04d}.mp4"
@@ -256,6 +268,10 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
if not math.isfinite(maximum) or maximum <= 0:
raise ValueError("invalid session force range")
simulated = manifest.get("simulate") is True
# The recording block decides whether a missing video is a failure. A session
# recorded before the block existed keeps the default, which expects video.
recording = RecordingOptions.from_block(manifest.get("recording"))
video_expected = recording.video
for trial in manifest["trials"]:
trial_id = trial["trial_id"]
path = (root / trial["directory"]).resolve()
@@ -270,7 +286,9 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
if not complete:
issues.append({"trial_id": trial_id, "reason": "incomplete_trial",
"detail": str(trial.get("reason") or record.get("status") or "")})
source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs)
source = _capture_source(
path / CAPTURE_DIRECTORY, root, inputs, video_expected=video_expected
)
if source.get("duplicate_frame_ids"):
issues.append({
"trial_id": trial_id, "reason": "duplicate_frame_identity",
@@ -391,6 +409,7 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
"formal_conclusion": False, "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": {},
"trials": manifest["trials"], "coverage": coverage,
"recording": recording.as_block(),
"uv_definition": manifest.get("uv_definition"),
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
@@ -525,7 +544,9 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"source_directory": str(root), "session_status": manifest["status"],
"status": "exploratory", "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"],
"trials": manifest["trials"],
"recording": RecordingOptions.from_block(manifest.get("recording")).as_block(),
"uv_definition": manifest["uv_definition"],
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
}
+9 -1
View File
@@ -52,6 +52,7 @@ from .frames_index import (
reference_holds,
resolve_capture_directory,
)
from .recording_options import RecordingOptions
from .session_review import review_session
SCHEMA_VERSION = "autocal-summary-1.0"
@@ -242,6 +243,14 @@ def build_windows(root: Path, review: dict) -> list[dict]:
windows_by_trial.setdefault(window["trial_id"], []).append(window)
cache: dict = {}
rows: list[dict] = []
# A multi-point recording has to join a hold to a frame that reached the
# video. A session recorded without video (the root manifest's ``recording``
# block) has no video membership to require; every recorded frame carries
# its own host time. A session with no block is treated as video-expected.
require_video = (
bool(review.get("source_format"))
and RecordingOptions.from_block(review.get("recording")).video
)
for trial in review["trials"]:
trial_id = trial["trial_id"]
trial_status = str(trial.get("status") or "")
@@ -261,7 +270,6 @@ def build_windows(root: Path, review: dict) -> list[dict]:
if window is not None:
matched.add((hold["step_index"], hold["t_start_host_mono_ns"]))
directory = _capture_directory(root, trial, window or {})
require_video = bool(review.get("source_format"))
if window is not None and hold["complete"]:
start, end, method = _window_interval(window, hold)
if directory is None:
@@ -6,6 +6,11 @@ it wrote, renames that directory into place, and only then replaces
before the rename leaves the previous pointer untouched; the acquisition state is
never rolled back because an export failed.
After the pointer moves, the same four files are mirrored into ``summary/`` at
the session root so the operator can open one obvious folder. The mirror is a
convenience copy: the pointer remains the only authority, and a failed mirror is
reported without moving the pointer back or changing the recording.
Nothing is published when the recorded inputs changed since the summary was
built: the numbers would no longer be reproducible from the files on disk.
"""
@@ -16,17 +21,30 @@ import csv
import hashlib
import html
import json
import logging
import shutil
import threading
from pathlib import Path
from typing import Callable
from uuid import uuid4
from .calibration_session import write_json
from .session_review import review_session
from .summary import SCHEMA_VERSION, build_summary
logger = logging.getLogger(__name__)
POINTER_NAME = "summary-latest.json"
POINTER_FORMAT = "autocal-summary-pointer-1.0"
EXPORT_FORMAT = "autocal-summary-export-1.0"
#: Where the readable copy of the last published summary lives, under the session root.
SUMMARY_DIR_NAME = "summary"
SUMMARY_FILES = (
"summary.json",
"summary.html",
"baseline_load_data.csv",
"sensitivity_values.csv",
)
BASELINE_HEADER = (
"schema_version", "summary_id", "session_id", "trial_id", "hold_id", "site_id",
@@ -260,7 +278,7 @@ def _write_export(directory: Path, summary: dict) -> dict[str, str]:
_write_csv(directory / "baseline_load_data.csv", BASELINE_HEADER, summary.get("windows") or [])
_write_csv(directory / "sensitivity_values.csv", SENSITIVITY_HEADER, summary.get("sensitivity") or [])
hashes = {}
for name in ("summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"):
for name in SUMMARY_FILES:
path = directory / name
if not path.is_file():
raise SummaryExportError(f"the export is missing {name}")
@@ -294,6 +312,105 @@ def _read_pointer(root: Path) -> dict:
return pointer if isinstance(pointer, dict) else {}
def _recorded_summary_id(summary_json: Path) -> str:
"""The ``summary_id`` inside an exported summary, or ``""`` when unreadable."""
try:
payload = json.loads(summary_json.read_text(encoding="utf-8"))
except (OSError, ValueError):
return ""
return str(payload.get("summary_id") or "") if isinstance(payload, dict) else ""
def published_summary_directory(root: Path) -> Path | None:
"""The folder an operator should open for the last published summary.
The readable ``summary/`` mirror, but only while it is the version the pointer
names: a failed mirror leaves the previous copy in place, and opening that
would show numbers the pointer no longer claims. Otherwise the directory the
pointer itself names, which is the authority. ``None`` means nothing has been
published yet; a caller should disable the action rather than invent a path.
"""
root = Path(root).resolve()
pointer = _read_pointer(root)
pointer_id = str(pointer.get("summary_id") or "") if pointer else ""
flat = root / SUMMARY_DIR_NAME
flat_summary = flat / "summary.json"
if flat.is_dir() and flat_summary.is_file():
if not pointer_id or _recorded_summary_id(flat_summary) == pointer_id:
return flat
if not pointer:
return None
path = root / str(pointer.get("path") or "")
return path if path.is_dir() else None
def _write_flat_summary(temporary: Path, source: Path, hashes: dict[str, str]) -> None:
"""Copy the published four files into a fresh directory and verify them.
The copy is written into ``temporary`` first, so a failure here never touches
the existing ``summary/``; it becomes the new readable copy only after every
byte matched the pointer's hashes.
"""
temporary.mkdir(parents=True, exist_ok=False)
for name in SUMMARY_FILES:
destination = temporary / name
shutil.copy2(source / name, destination)
if hashes.get(name) and _sha256_file(destination) != hashes[name]:
raise SummaryExportError(f"the summary copy of {name} does not match the export")
def _replace_directory(target: Path, temporary: Path) -> None:
"""Swap ``temporary`` into ``target``, restoring the old directory on failure."""
backup = target.parent / f".tmp-summary-old-{uuid4().hex[:8]}"
moved_old = False
if target.exists():
target.rename(backup)
moved_old = True
try:
temporary.rename(target)
except OSError:
if moved_old:
backup.rename(target)
raise
if moved_old:
try:
shutil.rmtree(backup)
except OSError:
# The new copy is in place; a leftover backup is untidy, not a failure.
logger.warning("Could not remove the previous summary copy: %s", backup)
def _publish_flat_copy(
root: Path,
source: Path,
hashes: dict[str, str],
on_warning: Callable[[str], None] | None,
) -> None:
"""Mirror the published summary under ``summary/``; report, never fail the export.
The pointer has already moved when this runs. The readable copy is a
convenience, so a failure here leaves the previous ``summary/`` and the
pointer exactly as they were and only tells the operator.
"""
temporary = root / f".tmp-summary-flat-{uuid4().hex[:8]}"
try:
_write_flat_summary(temporary, source, hashes)
_replace_directory(root / SUMMARY_DIR_NAME, temporary)
except Exception as exc: # noqa: BLE001 - a copy failure is reported, not fatal
if temporary.exists():
shutil.rmtree(temporary, ignore_errors=True)
message = (
f"the readable copy under {SUMMARY_DIR_NAME}/ was not written: {exc}. "
"The pointer still names the published summary; the recording is unchanged."
)
logger.warning("%s", message)
if on_warning is not None:
try:
on_warning(message)
except Exception: # noqa: BLE001 - a reporter must not break the export
logger.exception("The summary copy warning handler failed")
def verify_inputs(root: Path, summary: dict) -> None:
"""Refuse to publish numbers the files on disk can no longer reproduce."""
fresh = review_session(root)
@@ -311,8 +428,14 @@ def publish_summary(
*,
verify: bool = True,
cancelled=None,
on_warning: Callable[[str], None] | None = None,
) -> Path:
"""Write, verify, rename, then move the pointer. Returns the published directory."""
"""Write, verify, rename, move the pointer, then mirror ``summary/``.
Returns the published directory under ``derived/``. A failure of the readable
mirror is reported through ``on_warning`` (and the log) after the pointer has
moved; it never turns a published summary into a failed export.
"""
root = Path(root).resolve()
if not (root / "session.json").is_file():
raise SummaryExportError(f"not a session directory: {root}")
@@ -358,6 +481,8 @@ def publish_summary(
raise SummaryExportError(f"could not publish the summary: {exc}") from exc
pointer = _pointer_for(root, summary, target, hashes)
write_json(root / POINTER_NAME, pointer)
# The pointer is the authority from here on; the readable copy is optional.
_publish_flat_copy(root, target, hashes, on_warning)
return target
+35 -4
View File
@@ -11,12 +11,18 @@ import hashlib
import json
import math
import time
from collections.abc import Mapping
from pathlib import Path
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record import ExperimentRecorder
from .calibration_session import CalibrationSession, write_json
from .capture_guard import CaptureGuard, capture_verdict, PENDING
from .recording_options import (
RecordingOptions,
make_record_config,
require_codec_available,
)
from .sensor.uv_flat import Site
@@ -56,6 +62,9 @@ class ManualTrial:
self.worker = worker
self.session: CalibrationSession | None = None
self.entry: dict | None = None
#: The recording settings this session runs with (plan P2). The session
#: manifest locks them at establishment; a later trial reuses them.
self.recording = RecordingOptions()
self.hold_s = 20.0
self.completed_steps: list[dict] = []
self.reason = ""
@@ -91,6 +100,8 @@ class ManualTrial:
config_ref: str,
fps: float,
uv_outline: dict | None = None,
recording: RecordingOptions | None = None,
codec_availability: Mapping[str, bool] | None = None,
) -> Path:
if self.status in {"recording", "closing"}:
raise RuntimeError("a trial is already active")
@@ -106,6 +117,24 @@ class ManualTrial:
if not 10.0 <= float(hold_s) <= 30.0:
raise ValueError("hold time must be between 10 and 30 seconds")
self.hold_s = float(hold_s)
# The session is the owner of the video choice: it is written into the
# root manifest once and every later trial reuses it. The codec is
# checked before anything is written, so an encoder this machine cannot
# open is refused instead of silently replaced.
options = RecordingOptions() if recording is None else recording
if not isinstance(options, RecordingOptions):
raise TypeError("recording must be a RecordingOptions instance")
if self.session is None:
require_codec_available(options, codec_availability)
else:
locked = RecordingOptions.from_block(self.session.document.get("recording"))
if recording is not None and options != locked:
raise ValueError(
"recording settings are locked once the session has started; "
"finish this session before changing them"
)
options = locked
self.recording = options
packet = self.worker.latest()
if packet.capture_mono_ns is None:
raise RuntimeError("the input has no frame-bound host timestamp")
@@ -149,13 +178,14 @@ class ManualTrial:
"reference_source": "manual_operator_confirmation",
"reference_file": "reference.csv",
"uv_outline": uv_outline,
# The root manifest keeps this block once; the trial's own metadata
# repeats it so a single trial folder states what it recorded.
"recording": self.recording.as_block(),
}
if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key]
for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
raise ValueError("UV outline does not match the selected site mapping")
recorder = ExperimentRecorder(
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True)
)
recorder = ExperimentRecorder(make_record_config(self.recording, fps))
if self.session is None:
self.session = CalibrationSession(Path(root).resolve(), metadata)
elif Path(root).resolve() != self.session.directory.parent:
@@ -390,6 +420,7 @@ class ManualTrial:
reason=self.reason,
guard=self.guard,
elapsed_s=elapsed,
video_expected=self.recording.video,
)
if verdict.status == PENDING:
return self.status
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
"""The capture page's settings dialog: input source, bundle, session and recording.
The capture page itself shows the workflow; everything that is decided once per
session lives here, so that starting a capture cannot switch pages or hide the
live image behind a configuration form.
Three things this dialog owns beyond plain fields:
* **The codec probe.** Recording candidates are written and read back once per
process before they are offered. A codec this machine cannot open is disabled
with its reason shown, never silently replaced — a recording made under a
different codec than the session manifest claims is not the evidence it says it
is.
* **The size estimate.** Bitrate times the planned duration is an estimate (the
module says so wherever it is displayed) and the free space beside it is real;
when less than twice the estimate is free the dialog warns before anything is
recorded.
* **The bundle.** Loading one rasterises the finger geometry; the page keeps that
work, the button here only asks for it.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import outputs_dir
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..recording_options import CODEC_CANDIDATES, RecordingOptions, probe_codecs
from ..sequence_io import format_duration
logger = logging.getLogger(__name__)
#: Product models a session can be recorded under. The stored value is this id.
PRODUCT_MODELS: tuple[str, ...] = ("finger",)
#: Measured video bitrates used only for the volume estimate. The plan's baseline
#: measured about 27 Mbit/s for ``mp4v`` on this view and about 5 Mbit/s for the
#: H.264 sample; the slider is not an encoder parameter, so the number is labelled
#: an estimate wherever it is shown.
CODEC_MBIT_PER_S: dict[str, float] = {"mp4v": 27.0, "avc1": 5.0, "H264": 5.0}
DEFAULT_MBIT_PER_S = 10.0
#: Planning allowance per hold: load, settle, confirm and unload around the hold
#: itself. It only shapes the size estimate, never a recorded window.
STEP_OVERHEAD_S = 5.0
#: One probe per process. The probe writes three real clips, and every window in
#: this app would otherwise repeat it; the answer cannot change while the process
#: runs because it is a property of this machine's OpenCV build.
_PROBE_CACHE: dict[str, dict[str, bool]] = {}
def probe_once(scratch: Path | None = None) -> dict[str, bool]:
"""The codec availability for this machine, probed at most once per process."""
directory = Path(scratch) if scratch is not None else Path(outputs_dir()) / "codec_probe"
key = str(directory.resolve())
if key not in _PROBE_CACHE:
try:
_PROBE_CACHE[key] = dict(probe_codecs(directory))
except Exception as exc: # noqa: BLE001 - a probe is external and must not crash start-up
logger.exception("Codec probe failed: %s", exc)
_PROBE_CACHE[key] = {}
return dict(_PROBE_CACHE[key])
def format_size(value: float) -> str:
"""A byte count in the unit an operator thinks in."""
number = float(value)
for unit, scale in (("GB", 1e9), ("MB", 1e6), ("kB", 1e3)):
if number >= scale:
return f"{number / scale:.2f} {unit}"
return f"{number:.0f} B"
class CaptureSettingsDialog(QDialog):
"""Everything decided once per session, in one place."""
#: The operator asked for a bundle; the page (and the window) own the dialog.
bundleRequested = Signal()
#: A recorded option changed and should be remembered in the preferences.
recordingChanged = Signal()
def __init__(
self,
source_panel: QWidget,
prefs: dict,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle(t("cap.settings.title"))
self.resize(720, 700)
self.setModal(True)
self.prefs = prefs
self.source_panel = source_panel
self._availability: dict[str, bool] | None = None
self._probe_error = ""
self._planned_seconds = 0.0
self._locked = False
outer = QVBoxLayout(self)
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
page = QWidget(scroll)
layout = QVBoxLayout(page)
layout.setSpacing(10)
scroll.setWidget(page)
outer.addWidget(scroll, 1)
self.input_group = QGroupBox(page)
self.input_group.setObjectName("groupBox")
input_layout = QVBoxLayout(self.input_group)
# The input panel is the live image plus its controls. It stays bounded
# here: the dialog has four sections, and a preview that expands would push
# the bundle, session and recording settings below the fold on every screen.
source_panel.setMaximumHeight(320)
source_panel.preview.setMinimumSize(140, 90)
input_layout.addWidget(source_panel)
self.input_group.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
)
layout.addWidget(self.input_group)
self.bundle_group = QGroupBox(page)
self.bundle_group.setObjectName("groupBox")
bundle_row = QHBoxLayout(self.bundle_group)
self.bundle_label = QLabel(t("cap.settings.bundle_none"), self.bundle_group)
self.bundle_label.setWordWrap(True)
self.bundle_label.setObjectName("hintText")
self.bundle_button = QPushButton(self.bundle_group)
self.bundle_button.setObjectName("captureLoadBundle")
self.bundle_button.clicked.connect(self.bundleRequested)
bundle_row.addWidget(self.bundle_label, 1)
bundle_row.addWidget(self.bundle_button)
layout.addWidget(self.bundle_group)
self.session_group = QGroupBox(page)
self.session_group.setObjectName("groupBox")
session_grid = QGridLayout(self.session_group)
session_grid.setHorizontalSpacing(12)
session_grid.setVerticalSpacing(8)
self.model_label = QLabel(self.session_group)
self.sensor_model = QComboBox(self.session_group)
self.sensor_model.setObjectName("captureSensorModel")
self.sensor_model.addItems(list(PRODUCT_MODELS))
self.sn_label = QLabel(self.session_group)
self.sensor_sn = QLineEdit(self.session_group)
self.sensor_sn.setObjectName("captureSensorSn")
self.output_label = QLabel(self.session_group)
self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self.session_group)
self.output_path.setObjectName("captureOutputPath")
self.output_browse = QPushButton(self.session_group)
self.frame_label = QLabel(self.session_group)
self.frame = QLineEdit("sensor", self.session_group)
self.frame.setObjectName("captureCoordinateFrame")
self.max_force_label = QLabel(self.session_group)
self.max_force = QLineEdit(self.session_group)
self.max_force.setObjectName("captureMaxForce")
self.min_force_label = QLabel(self.session_group)
self.min_force = QLineEdit(self.session_group)
self.min_force.setObjectName("captureMinForce")
for row, (label, field) in enumerate(
(
(self.model_label, self.sensor_model),
(self.sn_label, self.sensor_sn),
(self.output_label, self.output_path),
(self.frame_label, self.frame),
(self.max_force_label, self.max_force),
(self.min_force_label, self.min_force),
)
):
session_grid.addWidget(label, row, 0)
session_grid.addWidget(field, row, 1)
session_grid.addWidget(self.output_browse, 2, 2)
self.simulate = QCheckBox(self.session_group)
self.simulate.setObjectName("captureSimulate")
session_grid.addWidget(self.simulate, 6, 0, 1, 3)
layout.addWidget(self.session_group)
self.recording_group = QGroupBox(page)
self.recording_group.setObjectName("groupBox")
recording_layout = QGridLayout(self.recording_group)
recording_layout.setHorizontalSpacing(12)
recording_layout.setVerticalSpacing(6)
self.video_check = QCheckBox(self.recording_group)
self.video_check.setObjectName("captureRecordVideo")
self.codec_label = QLabel(self.recording_group)
self.codec = QComboBox(self.recording_group)
self.codec.setObjectName("captureCodec")
self.lossless_check = QCheckBox(self.recording_group)
self.lossless_check.setObjectName("captureLossless")
self.codec_reason = QLabel(self.recording_group)
self.codec_reason.setWordWrap(True)
self.codec_reason.setObjectName("hintText")
self.estimate = QLabel(self.recording_group)
self.estimate.setWordWrap(True)
self.estimate.setObjectName("hintText")
self.locked_label = QLabel(self.recording_group)
self.locked_label.setWordWrap(True)
self.locked_label.setObjectName("hintText")
recording_layout.addWidget(self.video_check, 0, 0, 1, 2)
recording_layout.addWidget(self.codec_label, 1, 0)
recording_layout.addWidget(self.codec, 1, 1)
recording_layout.addWidget(self.lossless_check, 2, 0, 1, 2)
recording_layout.addWidget(self.codec_reason, 3, 0, 1, 2)
recording_layout.addWidget(self.estimate, 4, 0, 1, 2)
recording_layout.addWidget(self.locked_label, 5, 0, 1, 2)
layout.addWidget(self.recording_group)
layout.addStretch(1)
self.buttons = QDialogButtonBox(self)
self.close_button = self.buttons.addButton(
t("cap.settings.close"), QDialogButtonBox.ButtonRole.AcceptRole
)
self.close_button.setObjectName("captureSettingsClose")
outer.addWidget(self.buttons)
self.close_button.clicked.connect(self.close)
self.output_browse.clicked.connect(self._choose_output)
self.bundle_button.clicked.connect(self._choose_bundle)
self.video_check.toggled.connect(self._on_recording_changed)
self.codec.currentIndexChanged.connect(self._on_recording_changed)
self.lossless_check.toggled.connect(self._on_recording_changed)
self.output_path.textChanged.connect(self._refresh_estimate)
self.sensor_sn.textChanged.connect(self._refresh_estimate)
self.simulate.toggled.connect(self._refresh_estimate)
self.set_availability(probe_once(), error="")
self._load_preferences()
self.retranslate()
# ------------------------------------------------------------- preferences
def _load_preferences(self) -> None:
block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None
self._apply_options(RecordingOptions.from_block(block))
def _remember(self) -> None:
if not isinstance(self.prefs, dict):
return
# Only the recording block is a preference: the sensor SN, the frame name
# and the maximum force belong to the session being set up, not to the
# operator's stored UI state, so they are not carried across restarts.
self.prefs["recording"] = self.recording_options().as_block()
# ------------------------------------------------------------------ values
@property
def availability(self) -> dict[str, bool] | None:
"""The probed codec result the start path must honour (``None``: unprobed)."""
return None if self._availability is None else dict(self._availability)
def recording_options(self) -> RecordingOptions:
codec = self.codec.currentData()
return RecordingOptions(
video=self.video_check.isChecked(),
codec=str(codec or CODEC_CANDIDATES[0]),
lossless_frames=self.lossless_check.isChecked(),
)
def _apply_options(self, options: RecordingOptions) -> None:
self.video_check.setChecked(bool(options.video))
index = self.codec.findData(options.codec)
# A stored codec this machine cannot write must not become the selection:
# ``set_availability`` already fell back to one that works, and re-selecting
# the disabled entry would leave the operator pointing at a codec the start
# path is about to refuse.
if index >= 0 and self._codec_usable(index):
self.codec.setCurrentIndex(index)
self.lossless_check.setChecked(bool(options.lossless_frames))
def _codec_usable(self, index: int) -> bool:
item = self.codec.model().item(index)
return item is None or item.isEnabled()
def session_fields(self) -> dict:
"""The session parameters as the page reads them when it starts a session."""
return {
"sensor_model": self.sensor_model.currentText(),
"sensor_sn": self.sensor_sn.text().strip(),
"root": self.output_path.text().strip(),
"coordinate_frame": self.frame.text().strip(),
"max_force_n": self.max_force.text().strip(),
"min_force_n": self.min_force.text().strip(),
"simulate": self.simulate.isChecked(),
}
# ------------------------------------------------------------------- probe
def set_availability(self, availability: dict[str, bool] | None, *, error: str = "") -> None:
"""Offer only the codecs this machine can actually encode with."""
self._availability = None if availability is None else dict(availability)
self._probe_error = str(error or "")
self.codec.blockSignals(True)
try:
self.codec.clear()
for codec in CODEC_CANDIDATES:
label = codec
available = True
if self._availability is not None:
available = self._availability.get(codec) is True
if not available:
label = t("cap.recording.unavailable", codec=codec)
self.codec.addItem(label, codec)
item = self.codec.model().item(self.codec.count() - 1)
if item is not None:
item.setEnabled(available)
wanted = str(self._stored_codec() or CODEC_CANDIDATES[0])
index = self.codec.findData(wanted)
if index < 0:
index = 0
self.codec.setCurrentIndex(index)
# The stored codec can be one this machine no longer opens. Offer the
# first available one instead, and keep the reason above visible: the
# operator must not be left with a disabled selection and no way out.
for candidate in range(self.codec.count()):
item = self.codec.model().item(candidate)
if item is not None and item.isEnabled():
self.codec.setCurrentIndex(candidate)
break
finally:
self.codec.blockSignals(False)
self.codec.setToolTip(
"\n".join(
t("cap.recording.unavailable", codec=codec)
for codec, ok in (self._availability or {}).items()
if not ok
)
)
self._refresh_estimate()
def _stored_codec(self) -> str:
block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None
if isinstance(block, dict) and isinstance(block.get("codec"), str):
return block["codec"]
return CODEC_CANDIDATES[0]
def _on_recording_changed(self) -> None:
self._remember()
self._refresh_estimate()
self.recordingChanged.emit()
# ------------------------------------------------------------------ estimate
def set_plan(self, seconds: float) -> None:
"""How long the queue is planned to record for, in seconds."""
self._planned_seconds = max(0.0, float(seconds))
self._refresh_estimate()
def _disk_root(self) -> Path:
text = self.output_path.text().strip()
candidate = Path(text) if text else Path.cwd()
while not candidate.exists() and candidate != candidate.parent:
candidate = candidate.parent
return candidate if candidate.exists() else Path.cwd()
def _refresh_estimate(self) -> None:
options = self.recording_options()
if not options.video:
text = t("cap.recording.off")
if options.lossless_frames:
text += "\n" + t("cap.recording.lossless")
self.estimate.setText(text)
return
if self._planned_seconds <= 0:
self.estimate.setText(t("cap.recording.no_plan"))
return
codec = str(options.codec)
mbit = CODEC_MBIT_PER_S.get(codec, DEFAULT_MBIT_PER_S)
size = mbit * 1e6 / 8.0 * self._planned_seconds
lines = []
try:
free = shutil.disk_usage(self._disk_root()).free
except OSError:
free = None
if free is None:
lines.append(
t("cap.recording.estimate", size=format_size(size),
seconds=format_duration(self._planned_seconds), free="—")
)
else:
lines.append(
t("cap.recording.estimate", size=format_size(size),
seconds=format_duration(self._planned_seconds), free=format_size(free))
)
if free < 2.0 * size:
lines.append(t("cap.recording.disk_short"))
self.estimate.setText("\n".join(lines))
# -------------------------------------------------------------------- state
def set_locked(self, locked: bool) -> None:
"""A started session locks its recording and session parameters."""
if bool(locked) == self._locked and self.locked_label.isVisible() == self._locked:
return
self._locked = bool(locked)
for widget in (
self.input_group, self.bundle_button, self.session_group,
self.sensor_model, self.sensor_sn, self.output_path, self.output_browse,
self.frame, self.max_force, self.min_force, self.simulate,
self.video_check, self.codec, self.lossless_check,
):
widget.setEnabled(not self._locked)
self.locked_label.setText(t("cap.recording.locked") if self._locked else "")
self.locked_label.setVisible(self._locked)
def set_bundle(self, path: str) -> None:
text = str(path or "").strip()
self.bundle_label.setText(text or t("cap.settings.bundle_none"))
self.bundle_label.setToolTip(text)
def refresh_sizes(self) -> None:
"""Re-read free space; opening the dialog again refreshes the estimate."""
self._refresh_estimate()
def _choose_output(self) -> None:
path = QFileDialog.getExistingDirectory(self, t("calibration.output"), self.output_path.text())
if path:
self.output_path.setText(path)
def _choose_bundle(self) -> None:
self.bundleRequested.emit()
# -------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setWindowTitle(t("cap.settings.title"))
self.input_group.setTitle(t("cap.settings.input"))
self.bundle_group.setTitle(t("cap.settings.bundle"))
self.bundle_button.setText(t("cap.settings.bundle_load"))
self.session_group.setTitle(t("cap.settings.session"))
self.model_label.setText(t("calibration.sensor_model"))
self.sn_label.setText(t("calibration.sensor_sn"))
self.output_label.setText(t("calibration.output"))
self.output_browse.setText(t("source.browse"))
self.frame_label.setText(t("multipoint.frame"))
self.max_force_label.setText(t("multipoint.max_force"))
self.max_force.setPlaceholderText(t("cap.settings.max_required"))
self.min_force_label.setText(t("multipoint.min_force"))
self.min_force.setPlaceholderText(t("multipoint.min_placeholder"))
self.simulate.setText(t("cap.settings.simulate"))
self.recording_group.setTitle(t("cap.recording.title"))
self.video_check.setText(t("cap.recording.video"))
self.codec_label.setText(t("cap.recording.codec"))
self.lossless_check.setText(t("cap.recording.lossless"))
if self._probe_error:
self.codec_reason.setText(t("cap.recording.probe_failed", detail=self._probe_error))
else:
self.codec_reason.setText(
"\n".join(
t("cap.recording.unavailable", codec=codec)
for codec, ok in (self._availability or {}).items()
if not ok
)
)
self.close_button.setText(t("cap.settings.close"))
self._refresh_estimate()
def showEvent(self, event) -> None: # noqa: N802 - Qt override
super().showEvent(event)
self._refresh_estimate()
def closeEvent(self, event) -> None: # noqa: N802 - Qt override
self._remember()
super().closeEvent(event)
@@ -0,0 +1,112 @@
"""The processed camera image, shown beside the UV map on the capture page.
The capture page needs the live 2-D UV image and the pickable UV map *at the same
time*: the operator confirms a point's position on the map and watches the same
frame the recorder sees. This widget therefore reads the already-running input
worker; it never starts, stops or reconfigures it — the input source panel in the
settings dialog owns that.
Multiple readers of ``worker.latest()`` are allowed: the input panel keeps its own
copy for the settings dialog, and this view keeps one here.
"""
from __future__ import annotations
import numpy as np
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
class LiveUvView(QWidget):
"""Show the latest processed frame and the identity of the packet behind it."""
def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.source_panel = source_panel
self._identity: tuple[int, int] | None = None
self._image: QImage | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
self.preview = QLabel(self)
self.preview.setObjectName("liveUvPreview")
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
# No minimum of its own: the image scales to whatever room the row gives
# it, and a minimum would overflow the row the moment the page is squeezed.
self.preview.setMinimumSize(1, 1)
self.preview.setSizePolicy(
# The image scales to whatever room the row gives it; a minimum that
# honoured the frame's own size would push the page past the window.
QSizePolicy.Policy.Ignored,
QSizePolicy.Policy.Ignored,
)
self.status = QLabel(self)
self.status.setWordWrap(True)
self.status.setObjectName("hintText")
layout.addWidget(self.preview, 1)
layout.addWidget(self.status)
self._timer = QTimer(self)
self._timer.setInterval(100)
self._timer.timeout.connect(self.refresh)
self._timer.start()
self.retranslate()
self.refresh()
def retranslate(self) -> None:
if self._image is None:
self.preview.setText(t("source.no_frame"))
self.refresh()
def refresh(self) -> None:
worker = getattr(self.source_panel, "worker", None)
packet = None if worker is None else worker.latest()
if packet is not None:
identity = (int(packet.session_id), int(packet.frame_id))
if identity != self._identity:
self._identity = identity
self._show(packet.processed_frame)
running = bool(getattr(worker, "is_running", False))
if packet is None:
# The preview already says there is no frame; the line below it says
# whether the input is running, which is the fact an operator needs.
self.status.setText(t("source.running") if running else t("source.stopped"))
return
self.status.setText(
t("source.frame").format(
frame=packet.frame_id,
source_frame=getattr(packet, "source_frame_id", None) or "–",
basis=t("source.basis_unknown"),
time="—",
dropped=getattr(packet, "capture_dropped", 0),
)
)
def _show(self, frame) -> None:
data = np.asarray(frame)
if data.ndim != 3 or data.shape[2] != 3:
return
height, width = data.shape[:2]
self._image = QImage(
data.data, width, height, int(data.strides[0]), QImage.Format.Format_BGR888
).copy()
self._scale()
def _scale(self) -> None:
if self._image is None:
return
self.preview.setPixmap(
QPixmap.fromImage(self._image).scaled(
self.preview.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
def resizeEvent(self, event) -> None: # noqa: N802 - Qt override
super().resizeEvent(event)
self._scale()
def shutdown(self) -> None:
self._timer.stop()
@@ -1,746 +0,0 @@
"""02 multi-point acquisition. Manual loads only; this page never moves the platform."""
from __future__ import annotations
import numpy as np
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QComboBox,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from ..multipoint import Campaign, next_point_id
from ..sensor.frames import machine_table_xy
from ..sensor.uv_flat import site_at
#: A stored point and the mapping's answer for the same UV are the same point when
#: they agree this closely. Further apart means the map, the bundle or the mounting
#: moved under the record, and the point has to be taken again rather than retargeted.
_POINT_MATCH_MM = 0.05
_NORMAL_MATCH = 1e-3
class _WrappingLabel(QLabel):
"""Keep wrapped text inside its own row instead of painting over the next control."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWordWrap(True)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
height = self.heightForWidth(self.width())
if height > 0 and self.minimumHeight() != height:
self.setMinimumHeight(height)
self.updateGeometry()
class MultipointPanel(QWidget):
"""Add points, then record them in order after each load is confirmed by hand."""
alignRequested = Signal(object)
def __init__(self, host: QWidget, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.host = host
self.campaign = Campaign()
self._selected = ""
self._offered_liftoff = ""
self._finger = None
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
outer.addWidget(scroll)
page = QWidget(scroll)
page.setMinimumWidth(560)
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
scroll.setWidget(page)
self.hint = _WrappingLabel(page)
layout.addWidget(self.hint)
self.site_id = QLineEdit(page)
self.repeats = QSpinBox(page)
self.repeats.setRange(1, 99)
self.repeats.setValue(1)
self.repeats.setMinimumWidth(160)
self.frame = QLineEdit(page)
self.max_force = QLineEdit(page)
self.min_force = QLineEdit(page)
self.id_label = QLabel(page)
self.repeats_label = QLabel(page)
self.frame_label = QLabel(page)
self.force_label = QLabel(page)
self.min_label = QLabel(page)
form = QGridLayout()
form.setHorizontalSpacing(16)
form.setVerticalSpacing(10)
form.setColumnMinimumWidth(0, 156)
form.setColumnStretch(1, 1)
fields = (
(self.id_label, self.site_id),
(self.repeats_label, self.repeats),
(self.frame_label, self.frame),
(self.min_label, self.min_force),
(self.force_label, self.max_force),
)
for row, (label, field) in enumerate(fields):
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
form.addWidget(label, row, 0)
form.addWidget(field, row, 1)
layout.addLayout(form)
buttons = QHBoxLayout()
buttons.setSpacing(8)
self.add_button = QPushButton(page)
self.update_button = QPushButton(page)
self.remove_button = QPushButton(page)
self.up_button = QPushButton(page)
self.down_button = QPushButton(page)
for button in (
self.add_button, self.update_button, self.remove_button,
self.up_button, self.down_button,
):
buttons.addWidget(button, 1)
layout.addLayout(buttons)
extra = QHBoxLayout()
extra.setSpacing(8)
self.align_button = QPushButton(page)
self.reacquire_button = QPushButton(page)
for button in (self.align_button, self.reacquire_button):
extra.addWidget(button, 1)
layout.addLayout(extra)
self.table = QTableWidget(0, 8, page)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.table.setMinimumHeight(160)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.itemSelectionChanged.connect(self._on_select)
layout.addWidget(self.table, 1)
self.progress = _WrappingLabel(page)
self.save_status = _WrappingLabel(page)
self.capture_status = _WrappingLabel(page)
self.capture_status.setObjectName("multipointCapture")
layout.addWidget(self.progress)
layout.addWidget(self.save_status)
layout.addWidget(self.capture_status)
self.pose_status = _WrappingLabel(page)
layout.addWidget(self.pose_status)
self.source_label = QLabel(page)
self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.source_label.setMinimumWidth(156)
self.source = QComboBox(page)
self.source.addItem("", "weight")
self.source.addItem("", "wrench")
source_row = QHBoxLayout()
source_row.setSpacing(16)
source_row.addWidget(self.source_label)
source_row.addWidget(self.source, 1)
layout.addLayout(source_row)
self.wrench_box = QWidget(page)
wrench = QGridLayout(self.wrench_box)
wrench.setContentsMargins(0, 0, 0, 0)
wrench.setHorizontalSpacing(8)
wrench.setVerticalSpacing(8)
self.fx_label = QLabel(self.wrench_box)
self.fy_label = QLabel(self.wrench_box)
self.fz_label = QLabel(self.wrench_box)
self.fx_edit = QLineEdit(self.wrench_box)
self.fy_edit = QLineEdit(self.wrench_box)
self.fz_edit = QLineEdit(self.wrench_box)
self.rotation_label = QLabel(self.wrench_box)
self.rotation = QLineEdit(self.wrench_box)
wrench.addWidget(self.fx_label, 0, 0)
wrench.addWidget(self.fx_edit, 0, 1)
wrench.addWidget(self.fy_label, 0, 2)
wrench.addWidget(self.fy_edit, 0, 3)
wrench.addWidget(self.fz_label, 0, 4)
wrench.addWidget(self.fz_edit, 0, 5)
wrench.addWidget(self.rotation_label, 1, 0)
wrench.addWidget(self.rotation, 1, 1, 1, 5)
layout.addWidget(self.wrench_box)
actions = QGridLayout()
actions.setHorizontalSpacing(8)
actions.setVerticalSpacing(8)
self.simulate_button = QPushButton(page)
self.start_button = QPushButton(page)
self.liftoff_button = QPushButton(page)
self.remeasure_button = QPushButton(page)
self.stop_button = QPushButton(page)
actions.addWidget(self.simulate_button, 0, 0)
actions.addWidget(self.start_button, 0, 1)
actions.addWidget(self.liftoff_button, 1, 0, 1, 2)
actions.addWidget(self.remeasure_button, 2, 0, 1, 2)
actions.addWidget(self.stop_button, 3, 0, 1, 2)
actions.setColumnStretch(0, 1)
actions.setColumnStretch(1, 1)
layout.addLayout(actions)
self.message = _WrappingLabel(page)
layout.addWidget(self.message)
self.add_button.clicked.connect(self._add)
self.update_button.clicked.connect(self._update)
self.remove_button.clicked.connect(self._remove)
self.up_button.clicked.connect(lambda: self._move(-1))
self.down_button.clicked.connect(lambda: self._move(1))
self.align_button.clicked.connect(self._align)
self.reacquire_button.clicked.connect(self._reacquire)
self.source.currentIndexChanged.connect(lambda _index: self._refresh())
self.simulate_button.clicked.connect(lambda: self._begin(simulate=True))
self.start_button.clicked.connect(lambda: self._begin(simulate=False))
self.liftoff_button.clicked.connect(self._liftoff)
self.remeasure_button.clicked.connect(self._remeasure)
self.stop_button.clicked.connect(self._stop)
self._timer = QTimer(self)
self._timer.setInterval(200)
self._timer.timeout.connect(self._poll)
self._timer.start()
self._listed: tuple = ()
self._summary_published_for = None
self.retranslate()
self.site_id.setText(self._next_site_id())
self.frame.setText("sensor")
self._refresh()
@property
def is_active(self) -> bool:
return self.campaign.is_active
def bind_finger(self, panel) -> None:
"""The finger page whose picked points and solved poses this tab records."""
self._finger = panel
def shutdown(self) -> None:
self._timer.stop()
if self.campaign.is_active:
self.campaign.stop("application closed")
# Bounded only at close time: the window cannot wait forever, and a
# writer that misses the deadline leaves an explicitly incomplete repeat.
self.campaign.flush_close()
def retranslate(self) -> None:
self.hint.setText(t("multipoint.hint"))
self.id_label.setText(t("multipoint.site_id"))
self.repeats_label.setText(t("multipoint.repeats"))
self.frame_label.setText(t("multipoint.frame"))
self.force_label.setText(t("multipoint.max_force"))
self.min_label.setText(t("multipoint.min_force"))
self.frame.setPlaceholderText(t("multipoint.frame_placeholder"))
self.max_force.setPlaceholderText(t("multipoint.force_placeholder"))
self.min_force.setPlaceholderText(t("multipoint.min_placeholder"))
self.add_button.setText(t("multipoint.add"))
self.update_button.setText(t("multipoint.update"))
self.remove_button.setText(t("multipoint.remove"))
self.up_button.setText(t("multipoint.up"))
self.down_button.setText(t("multipoint.down"))
self.align_button.setText(t("multipoint.align"))
self.reacquire_button.setText(t("multipoint.reacquire"))
self.source_label.setText(t("multipoint.source"))
self.source.setItemText(0, t("multipoint.source_weight"))
self.source.setItemText(1, t("multipoint.source_wrench"))
self.fx_label.setText(t("multipoint.fx"))
self.fy_label.setText(t("multipoint.fy"))
self.fz_label.setText(t("multipoint.fz"))
self.rotation_label.setText(t("multipoint.rotation"))
self.rotation.setPlaceholderText(t("multipoint.rotation_placeholder"))
self.simulate_button.setText(t("multipoint.simulate"))
self.start_button.setText(t("multipoint.start"))
self.liftoff_button.setText(t("multipoint.liftoff"))
self.remeasure_button.setText(t("multipoint.remeasure"))
self.remeasure_button.setToolTip(t("multipoint.remeasure_ready"))
self.stop_button.setText(t("multipoint.stop"))
self.table.setHorizontalHeaderLabels([
t("multipoint.col_id"), t("multipoint.col_x"), t("multipoint.col_y"),
t("multipoint.col_z"), t("multipoint.col_levels"), t("multipoint.col_hold"),
t("multipoint.col_repeats"), t("multipoint.col_done"),
])
self._refresh()
def _next_site_id(self) -> str:
return next_point_id([site.site_id for site in self.campaign.sites])
def _levels(self) -> list[float]:
return [float(part.strip()) for part in self.host.levels.text().split(",") if part.strip()]
def _hold(self) -> float:
return float(self.host.hold.value())
def _solver(self):
finger = self._finger
stack = None if finger is None else getattr(finger, "stack", None)
if stack is None:
raise ValueError(t("multipoint.no_solver"))
return stack
@staticmethod
def _pose_of(solved, stack) -> dict:
"""The record of one solve: the targets, and what the solver said of them."""
move_x, move_y = machine_table_xy(solved.table_xy_mm)
return {
"x": float(move_x),
"y": float(move_y),
"yaw": float(solved.yaw_deg),
"pitch": float(solved.pitch_deg),
"z": float(solved.contact_z_mm),
"residual_deg": float(solved.residual_deg),
"upright": bool(solved.upright),
"in_range": bool(solved.ok and solved.in_range),
"ok": bool(solved.ok),
"normal_aligned_to": f"tool_axis:{stack.tool_axis}",
"reason": str(solved.reason or ""),
}
def _solve_uv(self, u: float, v: float):
"""Pose for the point currently chosen on 选择测点. Does not move the platform."""
flat = getattr(self.host, "_flat", None)
if flat is None:
raise ValueError(t("multipoint.no_map"))
site = site_at(flat, int(round(u)), int(round(v)), origin=getattr(self.host, "_origin", None))
sample = flat.sample(*site.raster_xy_px)
if sample is None:
raise ValueError(t("multipoint.mesh_required"))
point, normal, _face = sample
stack = self._solver()
solved = stack.solve(
np.asarray(point, dtype=np.float64),
np.asarray(normal, dtype=np.float64),
)
length = float(np.linalg.norm(normal))
unit = tuple(float(value) / length for value in normal) if length >= 1e-9 else tuple(
float(value) for value in normal
)
return (
(float(point[0]), float(point[1]), float(point[2])),
unit,
self._pose_of(solved, stack),
(float(site.u_px), float(site.v_px)),
)
def _solve_point(self, point, normal) -> dict:
"""Pose for a recorded sensor-frame point, against the geometry in force now."""
stack = self._solver()
solved = stack.solve(
np.asarray(point, dtype=np.float64),
np.asarray(normal, dtype=np.float64),
)
return self._pose_of(solved, stack)
def _solve_current(self):
return self._solve_uv(float(self.host.u_spin.value()), float(self.host.v_spin.value()))
def _store_pose(self, site_id: str, point, normal, pose, uv) -> None:
site = next(item for item in self.campaign.sites if item.site_id == site_id)
site.x, site.y, site.z = point
site.normal = normal
site.pose = pose
site.uv_px = uv
site.position_frame = "sensor"
def _store_solved_pose(self, site_id: str, pose: dict) -> None:
"""Record a fresh solve of a point without retaking the point itself."""
self.campaign.store_solved_pose(site_id, pose)
def _require_same_point(self, site, point, normal) -> None:
"""Refuse a point the current map no longer puts where the record says.
A re-solve is allowed to answer differently; it is not allowed to move the
point quietly. The recorded position is what a session's rows are
attributed to, so a changed map, bundle or mounting has to reach the
operator as "take this point again", never as a silently retargeted move.
"""
if site.position_frame != "sensor" or site.normal is None:
return
stored = np.asarray((site.x, site.y, site.z), dtype=np.float64)
moved = float(np.linalg.norm(np.asarray(point, dtype=np.float64) - stored))
if moved > _POINT_MATCH_MM:
raise ValueError(t("multipoint.point_moved").format(mm=f"{moved:.2f}"))
dot = float(np.dot(
np.asarray(normal, dtype=np.float64),
np.asarray(site.normal, dtype=np.float64),
))
turned = float(np.degrees(np.arccos(np.clip(dot, -1.0, 1.0))))
if turned > _NORMAL_MATCH:
raise ValueError(t("multipoint.normal_moved").format(deg=f"{turned:.2f}"))
def _resolve_site(self, site) -> dict:
"""The pose to align now, solved against the geometry in force now.
``site.pose`` records what the solver said when the point was taken. The
mounting offsets, the mounting angle, the tool position and the travel
limits are all editable afterwards, so a stored pose can name a target
this machine no longer reaches — or reach the same point a different way.
The closed form is cheap and stateless, so this asks it again instead of
keeping a cache and a rule for when to distrust it.
"""
if site.uv_px is not None:
point, normal, pose, _uv = self._solve_uv(*site.uv_px)
self._require_same_point(site, point, normal)
self._store_solved_pose(site.site_id, pose)
return pose
if site.normal is None or site.position_frame != "sensor":
raise ValueError(t("multipoint.point_unmapped"))
pose = self._solve_point((site.x, site.y, site.z), site.normal)
self._store_solved_pose(site.site_id, pose)
return pose
def _pose_for_display(self, site):
"""What the status line shows: solved now, or the record when that fails.
The line is read before a move, so it has to answer for the geometry in
force now; a stored pose the current mounting no longer reaches would
otherwise read as reachable right up to the refusal.
"""
if site is None:
return None
if self.campaign.is_active:
return site.pose
try:
return self._resolve_site(site)
except (OSError, RuntimeError, ValueError):
return site.pose
def _max_force(self) -> float:
text = self.max_force.text().strip()
if not text:
raise ValueError(t("multipoint.force_required"))
return float(text)
def _min_force(self) -> float:
text = self.min_force.text().strip()
return 0.0 if not text else float(text)
def _add(self) -> None:
def go():
if not self.site_id.text().strip():
self.site_id.setText(self._next_site_id())
point, normal, pose, uv = self._solve_current()
self.campaign.add_solved_site(
self.site_id.text(), point[0], point[1], point[2], normal, pose,
self._levels(), self._hold(), self.repeats.value(),
max_force_n=self._max_force(), min_force_n=self._min_force(), uv_px=uv,
)
self.table.clearSelection()
self._selected = ""
self.site_id.setText(self._next_site_id())
self._run(go)
def _update(self) -> None:
if not self._selected:
return
def go():
point, normal, pose, uv = self._solve_current()
new_id = self.site_id.text().strip()
self.campaign.update_site(
self._selected, point[0], point[1], point[2], self._levels(), self._hold(),
self.repeats.value(), max_force_n=self._max_force(), new_id=new_id,
)
self._store_pose(new_id or self._selected, point, normal, pose, uv)
self._run(go)
def _remove(self) -> None:
if self._selected:
self._run(lambda: self.campaign.remove_site(self._selected))
def _move(self, delta: int) -> None:
if self._selected:
self._run(lambda: self.campaign.move_site(self._selected, delta))
def _begin(self, *, simulate: bool) -> None:
def go():
if self.host.trial.status in {"recording", "closing"}:
raise RuntimeError(t("multipoint.finger_busy"))
self._summary_published_for = None
if not simulate:
worker = self.host.source_panel.worker
packet = worker.latest()
mode = getattr(self.host.source_panel, "mode", None)
if not worker.is_running or packet is None or mode is None:
raise RuntimeError(t("multipoint.camera_required"))
directory = self.campaign.start(
self.host.output_path.text().strip(),
sensor_model=self.host.sensor_model.currentText(),
sensor_sn=self.host.sensor_sn.text(),
coordinate_frame=self.frame.text().strip() or "sensor",
max_force_n=self._max_force(),
min_force_n=self._min_force(),
simulate=simulate,
)
if not simulate:
try:
self.campaign.attach_capture(
self.host.source_panel.worker,
fps=float(self.host.source_panel.mode.fps),
)
except (OSError, RuntimeError, ValueError) as exc:
# A repeat that cannot record its frames must not be offered
# for confirmation: stop it rather than collect unattached
# holds that would look complete.
self.campaign.stop(str(exc))
raise
self.message.setText(str(directory))
self._run(go)
def _confirm(self) -> None:
if self.source.currentData() == "wrench":
parts = [part for part in self.rotation.text().replace(",", " ").split() if part]
self._run(lambda: (
self.campaign.set_force_rotation([float(part) for part in parts]),
self.campaign.confirm_wrench(
float(self.fx_edit.text()), float(self.fy_edit.text()), float(self.fz_edit.text()),
),
))
return
self._run(lambda: self.campaign.confirm_reference(float(self.host.reference.text())))
def confirm_loaded(self) -> None:
"""The right-hand confirm button records the current multi-point step."""
self._confirm()
def _can_align(self) -> bool:
return bool(self._selected) and (
not self.campaign.is_active
or self._selected == self.campaign.pending_alignment_site
)
def _align(self) -> None:
if not self._selected:
self.message.setText(t("multipoint.align_select"))
return
if not self._can_align():
# Moving the platform in the middle of a run would invalidate the trial
# being recorded, so a second point waits until this one is finished.
self.message.setText(t("multipoint.align_busy"))
return
def go():
site = next(item for item in self.campaign.sites if item.site_id == self._selected)
# Solved, not looked up: the stored pose may predate any of the
# mounting, tool or travel settings the operator has changed since.
pose = self._resolve_site(site)
if not pose.get("ok") or not pose.get("in_range"):
raise ValueError(t("multipoint.pose_refused").format(reason=pose.get("reason") or ""))
self.alignRequested.emit(self.campaign.command_targets(self._selected))
self._run(go)
def _reacquire(self) -> None:
if self._selected:
self._summary_published_for = None
self._run(lambda: self.campaign.reacquire(self._selected))
def _liftoff(self) -> None:
self._run(self.campaign.confirm_liftoff)
def _remeasure(self) -> None:
def go():
self.campaign.remeasure_step()
self.message.setText(t("multipoint.remeasure_ready"))
self._run(go)
def _stop(self) -> None:
self.campaign.stop(t("multipoint.stopped_reason"))
self._refresh()
def _poll(self) -> None:
try:
self.campaign.poll()
except (OSError, RuntimeError, ValueError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
if self.campaign.is_active:
self.campaign.stop(str(exc))
if self.campaign.phase == "hold" and self.campaign.remaining_s() <= 0:
try:
self.campaign.complete_hold()
except (OSError, RuntimeError, ValueError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
if self.campaign.is_active:
self.campaign.stop(str(exc))
self._publish_summary_when_finished()
self._refresh()
def _publish_summary_when_finished(self) -> None:
"""The candidate export starts when the campaign has closed its evidence."""
directory = self.campaign.directory
if directory is None or self.campaign.phase not in {"done", "aborted"}:
return
if self.campaign.capture_state == "closing":
return
if directory == self._summary_published_for:
return
if self.host.publish_summary_for(directory):
self._summary_published_for = directory
def _run(self, action) -> None:
try:
action()
except (OSError, RuntimeError, ValueError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self._refresh()
def _on_select(self) -> None:
rows = self.table.selectionModel().selectedRows()
if not rows:
return
site = self.campaign.sites[rows[0].row()]
self._selected = site.site_id
self.site_id.setText(site.site_id)
self.repeats.setValue(site.repeats)
if site.uv_px is not None:
self.host.u_spin.setValue(int(round(site.uv_px[0])))
self.host.v_spin.setValue(int(round(site.uv_px[1])))
def _refresh(self) -> None:
finished = {item["site_id"]: item for item in self.campaign.completion()}
listed = tuple(
(
site.site_id, site.x, site.y, site.z, site.levels_n, site.hold_s, site.repeats,
finished[site.site_id]["repeats_complete"],
)
for site in self.campaign.sites
)
if listed != self._listed:
self._listed = listed
self.table.setRowCount(len(self.campaign.sites))
for row, site in enumerate(self.campaign.sites):
item = finished[site.site_id]
values = [
site.site_id, f"{site.x:g}", f"{site.y:g}", f"{site.z:g}",
", ".join(f"{value:g}" for value in site.levels_n),
f"{site.hold_s:g}", str(site.repeats),
f"{item['repeats_complete']}/{item['repeats_planned']}",
]
for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value))
active = self.campaign.is_active
busy = self.campaign.busy
wrench = self.source.currentData() == "wrench"
self.wrench_box.setVisible(wrench)
for widget in (
self.add_button, self.update_button, self.remove_button, self.up_button,
self.down_button, self.simulate_button, self.start_button,
self.site_id, self.repeats, self.frame, self.max_force, self.min_force, self.source,
):
widget.setEnabled(not busy)
for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation):
widget.setEnabled(self.campaign.phase == "load" and wrench)
self.liftoff_button.setEnabled(self.campaign.phase == "liftoff")
self.remeasure_button.setEnabled(self.campaign.can_remeasure_step())
self.stop_button.setEnabled(active)
self.reacquire_button.setEnabled(
not busy and bool(self._selected) and self.campaign.directory is not None
)
state = self.campaign.progress()
next_id = state["site_id"] if self.campaign.phase == "liftoff" else ""
if next_id and next_id != self._offered_liftoff:
self._offered_liftoff = next_id
for row, site in enumerate(self.campaign.sites):
if site.site_id == next_id:
self.table.selectRow(row)
break
elif self.campaign.phase != "liftoff":
self._offered_liftoff = ""
self.align_button.setEnabled(self._can_align())
branch = {
"baseline": t("multipoint.branch_baseline"),
"load": t("multipoint.branch_load"),
"unload": t("multipoint.branch_unload"),
}.get(state["branch"], state["branch"])
if self.campaign.phase == "liftoff":
self.progress.setText(t("multipoint.liftoff_wait").format(
site=state["site_id"], done=state["done"], total=state["total"],
))
elif state["phase"] in {"load", "hold"}:
self.progress.setText(t("multipoint.progress").format(
site=state["site_id"], site_index=state["site_index"],
site_count=state["site_count"], branch=branch,
load=state["target_n"], repeat=state["repeat_index"],
repeats=state["repeats"], done=state["done"], total=state["total"],
seconds=self.campaign.remaining_s(),
))
else:
self.progress.setText(t("multipoint.idle"))
shown = next(
(site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)),
None,
)
pose = self._pose_for_display(shown)
if pose:
text = t("multipoint.pose").format(
x=pose["x"], y=pose["y"], yaw=pose["yaw"], pitch=pose["pitch"],
z=pose["z"], residual=float(pose.get("residual_deg") or 0.0),
)
if not pose.get("in_range"):
text += "\n" + t("multipoint.pose_refused").format(reason=pose.get("reason") or "")
self.pose_status.setText(text)
else:
self.pose_status.setText(t("multipoint.no_pose"))
if self.campaign.phase == "done":
save = t("multipoint.save_done")
elif self.campaign.phase == "aborted":
save = t("multipoint.save_kept")
elif active or self.campaign.closing:
save = t("multipoint.save_writing")
else:
save = t("multipoint.save_idle")
self.save_status.setText(t("multipoint.save").format(status=save, path=state["directory"] or "—"))
self.capture_status.setText(self._capture_text(state))
def _capture_text(self, state: dict) -> str:
"""Where the recording is, and why it is not complete, in one line."""
capture = state.get("capture") or {}
status = capture.get("state") or "idle"
key = {
"idle": "multipoint.capture_idle",
"recording": "multipoint.capture_recording",
"closing": "multipoint.capture_closing",
"complete": "multipoint.capture_complete",
"incomplete": "multipoint.capture_incomplete",
"not_recorded": "multipoint.capture_not_recorded",
}.get(status, "multipoint.capture_idle")
reason = capture.get("reason") or ""
reason_key = {
"source_restarted": "multipoint.capture_reason_restarted",
"zero_frames": "multipoint.capture_reason_zero_frames",
"write_error": "multipoint.capture_reason_write_error",
"video_missing": "multipoint.capture_reason_video_missing",
"video_undecodable": "multipoint.capture_reason_video_undecodable",
"dropped_data": "multipoint.capture_reason_dropped",
"dropped_video": "multipoint.capture_reason_dropped",
"segment_count": "multipoint.capture_reason_segments",
"closing_timeout": "multipoint.capture_reason_timeout",
"source_stopped": "multipoint.capture_reason_source_stopped",
"not_recorded": "multipoint.capture_reason_not_recorded",
}.get(reason)
return t("multipoint.capture").format(
status=t(key),
frames=int(capture.get("frames") or 0),
dropped=int(capture.get("dropped_data") or 0) + int(capture.get("dropped_video") or 0),
reason=t(reason_key) if reason_key else (reason or "—"),
)
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
)
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
from ..recording_options import RecordingOptions
from ..session_review import review_session
from ..record_diagnostics import trial_diagnostics
from ..sensor.uv_outline import recorded_outline
@@ -255,7 +256,9 @@ class RecordOverviewDialog(QDialog):
model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—",
date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—",
status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress"
else t("calibration." + self.review["session_status"])))
else t("calibration." + self.review["session_status"]))
+ ("" if RecordingOptions.from_block(self.review.get("recording")).video
else "\n" + t("overview.video_not_recorded")))
self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory))
selected = self.table.currentRow()
self.rows = overview_rows(self.review, self.plan)
@@ -0,0 +1,59 @@
"""Open a file or folder in the platform's file manager.
One implementation for the menu's "show config" actions and for the capture page's
session row: the operator has the recording path in front of them and wants the
folder, not a path string to paste. Deliberately Qt-free — it hands the path to
the OS, and a headless test can assert the exact argument the file manager gets.
"""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
def reveal(path: str | Path | None, select: str | Path | None = None) -> bool:
"""Show ``path`` in the platform file manager; return whether it launched.
On Windows a ``select`` names a file to highlight, which is the
``explorer /select,<file>`` form. Explorer expects that switch and the quoted
path as one command-line string; passing the two as separate argv entries
breaks on a path containing a space. When the selected file has gone since
the operator clicked, the containing folder is opened instead of failing
silently.
A missing path, an empty path or a failed launch returns ``False`` and is
logged: this is a convenience action, never a reason to raise into the event
loop that called it.
"""
if path is None or not str(path).strip():
return False
target = Path(path)
if not target.exists():
logger.warning("Cannot reveal a path that does not exist: %s", target)
return False
folder = target if target.is_dir() else target.parent
selected = None if select is None else Path(select)
if selected is not None and not selected.exists():
logger.warning("The file to select is gone; opening its folder instead: %s", selected)
selected = None
command: str | list[str]
if sys.platform == "win32":
if selected is not None:
command = f'explorer /select,"{selected}"'
else:
command = ["explorer", str(folder)]
elif sys.platform == "darwin": # pragma: no cover - Windows is the target
command = ["open", str(folder)]
else: # pragma: no cover - Windows is the target
command = ["xdg-open", str(folder)]
try:
subprocess.Popen(command)
except OSError:
logger.exception("Could not open %s", folder)
return False
return True
@@ -1,4 +1,9 @@
"""Finger debug screen: pick a calibration point on the flat image, get a pose.
"""Geometry and mounting screen: pick one calibration point on the flat image.
The page used to keep a table of picked points and export it. The queue on the
capture page owns points now, so this screen is the single-point trial it always
was underneath: pick a point, read the geometry, watch the simulated pose and the
3D reach preview, and optionally run that one pose.
Workflow, with no platform connected:
@@ -18,8 +23,6 @@ not.
from __future__ import annotations
import csv
import math
from pathlib import Path
import numpy as np
@@ -29,17 +32,13 @@ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QFileDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
@@ -49,10 +48,7 @@ from ..motion.pose_move import PressAxis, press_targets
from ..sensor.export_bundle import SensorBundle
from ..sensor.frames import describe as describe_frames
from ..sensor.frames import machine_table_xy
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
#: How many picked points the list keeps before the oldest is dropped.
MAX_POINTS = 200
from ..sensor.uv_flat import FlatSurface, uv_origin
#: How close a stored point must be to a raster entry to be shown on the flat
#: image. One pixel of the 1024-wide raster is about 0.0147 mm on this export, so
@@ -223,31 +219,23 @@ class FlatSurfaceView(QLabel):
class FingerPanel(QGroupBox):
"""The whole debug screen: bundle, picker, readouts, point list, preview."""
"""Geometry and mounting: bundle, single-point picker, readouts and preview.
The panel keeps exactly one picked point. A queue of points belongs to the
capture page; this screen is where one point is tried, and where the mounting
numbers that turn a sensor-frame point into a platform pose are entered.
"""
loadRequested = Signal()
pickRequested = Signal(int, int)
stackChanged = Signal()
#: Run the selected point's solved pose on the platform, and stop moving.
#: Run the picked point's solved pose on the platform, and stop moving.
executeRequested = Signal()
stopRequested = Signal()
#: Whether to ask before moving. Persisted; the window honours it for every
#: pose move, including the rail's return-to-zero.
confirmChanged = Signal(bool)
#: Columns of the point table, minus the leading select checkbox. The first six
#: are the ones an operator may edit — the sensor-frame point and its normal —
#: and everything after them is *solved*, so it is redrawn from the edit rather
#: than typed into.
COLUMNS: tuple[str, ...] = (
"x", "y", "z", "nx", "ny", "nz",
"X", "Y", "Z", "yaw", "pitch", "residual", "u_px", "v_px",
)
#: How many of ``COLUMNS`` are inputs rather than solved outputs.
INPUT_COLUMNS = 6
#: Offset of the first data column; column 0 holds the select checkbox.
SELECT_COLUMN = 0
def __init__(
self,
stack: PlatformStack | None = None,
@@ -262,13 +250,8 @@ class FingerPanel(QGroupBox):
self.setObjectName("groupBox")
self._stack = stack or PlatformStack()
self._bundle: SensorBundle | None = None
#: Full-precision picked rows, index-aligned with the table.
self._picked: list[dict[str, float]] = []
#: True while this panel is writing the table itself, so the ``itemChanged``
#: handler can tell its own redraw from an operator's edit. Without it every
#: solved-column update would be re-read as an edit and re-solve.
self._writing = False
self._headers = self.COLUMNS
#: The one picked point, at full precision. ``None`` until a pick lands.
self._point: dict[str, float] | None = None
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
@@ -289,20 +272,9 @@ class FingerPanel(QGroupBox):
column.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
body.addWidget(picker_box, 2)
body.addWidget(readout_box, 3)
body.addWidget(preview_box, 5)
# The body outranks the table, because the 3D view is the pane an operator
# actually reads the pose from, and at an even split it came out 240 px tall
# — a box too short to judge a normal in, under three lines of legend.
body.addWidget(preview_box, 6)
outer.addLayout(body, 1)
# ``Maximum`` so the table stops at its natural height and the spare vertical
# space goes to the 3D view above it. Left expandable it ate the window: the
# preview stayed at its 240 px floor while the table had hundreds of empty
# pixels under three rows.
table_box = self._build_point_table()
table_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
outer.addWidget(table_box)
self.set_sensor_setup(sensor_offset_mm, sensor_yaw_deg)
self.set_tool_xy(*tool_xy_mm)
self.retranslate()
@@ -582,62 +554,6 @@ class FingerPanel(QGroupBox):
self._preview_box = box
return box
def _build_point_table(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
column = QVBoxLayout(box)
column.setContentsMargins(8, 6, 8, 8)
column.setSpacing(6)
self.table = QTableWidget(0, len(self._headers) + 1, box)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
# Editable, but only on a deliberate double-click: a single click selects
# the row (and drives the markers), which is what an operator does most.
self.table.setEditTriggers(
QTableWidget.EditTrigger.DoubleClicked
| QTableWidget.EditTrigger.EditKeyPressed
)
self.table.setAlternatingRowColors(True)
self.table.setColumnWidth(0, 34)
for index in range(1, len(self._headers) + 1):
self.table.setColumnWidth(index, 82)
self.table.horizontalHeader().setSectionResizeMode(
len(self._headers), QHeaderView.ResizeMode.Stretch
)
self.table.setMinimumHeight(90)
self.table.itemChanged.connect(self._on_item_changed)
self.table.itemSelectionChanged.connect(self._on_selection_changed)
column.addWidget(self.table, 1)
row = QHBoxLayout()
self.clear_button = QPushButton(box)
self.clear_button.clicked.connect(self.clear_points)
self.remove_button = QPushButton(box)
self.remove_button.clicked.connect(self.remove_selected)
self.up_button = QPushButton(box)
self.up_button.clicked.connect(lambda: self.move_selected(-1))
self.down_button = QPushButton(box)
self.down_button.clicked.connect(lambda: self.move_selected(1))
self.export_sites_button = QPushButton(box)
self.export_sites_button.setObjectName("exportCalibrationSites")
self.export_sites_button.clicked.connect(self._export_sites_dialog)
for button in (self.clear_button, self.remove_button, self.up_button, self.down_button):
row.addWidget(button)
row.addWidget(self.export_sites_button)
row.addStretch(1)
self._selected_label = QLabel(box)
self._selected_label.setObjectName("hintLabel")
row.addWidget(self._selected_label)
self.count_label = QLabel(box)
self.count_label.setObjectName("hintLabel")
row.addWidget(self.count_label)
column.addLayout(row)
self._table_box = box
return box
# -------------------------------------------------------------------- state
@property
@@ -699,16 +615,25 @@ class FingerPanel(QGroupBox):
self.preview.set_tool_xy(*self.tool_xy_mm)
def _resolve_everything(self) -> None:
"""Re-solve every stored row, then redraw what the current one shows.
"""The setup or the tool moved: redraw what the picked point now solves to.
Every row, not only the current one: the setup and the tool move the whole
machine, so a table still holding the old X/Y/Z would be a table of numbers
from a machine that no longer exists.
The capture page's queue is re-solved by that page when this signal fires;
this screen only owns its single picked point, so it re-solves that one and
repaints both views with the machine that exists now.
"""
for row in range(len(self._picked)):
self.resolve_row(row)
self._after_points_changed()
self._refresh_path()
record = self._point
if record is not None:
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
pose = self._stack.solve(point, normal)
self._last_pose = pose
self.preview.set_stack(self._stack)
self.preview.set_point(point, normal)
self.preview.set_pose(pose)
self.preview.set_highlight(point)
self._sync_sim_button()
self.refresh_readouts(point, normal, pose)
self._refresh_path()
self._refresh_reach()
self.stackChanged.emit()
@@ -716,6 +641,9 @@ class FingerPanel(QGroupBox):
self.path_edit.setText(path)
def set_bundle(self, bundle: SensorBundle | None, flat: FlatSurface | None = None) -> None:
# A different mesh means a previously picked point names a surface that no
# longer exists, so the single-point trial starts over.
self.clear_point()
self._bundle = bundle
self.view.set_flat(flat)
self.preview.set_bundle(bundle)
@@ -836,110 +764,30 @@ class FingerPanel(QGroupBox):
else:
self.view_label.setText(t("finger.untextured"))
def clear_points(self) -> None:
self._picked.clear()
self._writing = True
try:
self.table.setRowCount(0)
finally:
self._writing = False
def clear_point(self) -> None:
"""Forget the picked point, and the pictures drawn for it."""
self._point = None
self._last_pose = None
self._last_face = -1
self.preview.set_pose(None)
self.preview.set_point(None, None)
self.preview.set_highlight(None)
self.view.set_marker(None)
self.view.set_selected(None)
self._sync_sim_button()
self._refresh_count()
self.path_label.setText("")
def point_count(self) -> int:
return self.table.rowCount()
return 0 if self._point is None else 1
def points(self) -> list[dict[str, float]]:
"""Every picked point, at full precision, for a later export."""
return [dict(row) for row in self._picked]
"""The single picked point, at full precision (empty before a pick).
def acquisition_points(self) -> list[dict]:
"""Picked points with a freshly solved pose for 02 acquisition.
The pose is the same ``PlatformStack.solve`` result the execute button uses.
Calling this does not move the platform.
A list, not a bare dict, because the old table-based callers asked for a
sequence; this screen tries one point and the capture page's queue is where
many are kept.
"""
points = []
for index, record in enumerate(self._picked, 1):
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
pose = self._stack.solve(point, normal)
move_x, move_y = machine_table_xy(pose.table_xy_mm)
points.append({
"site_id": f"site-{index:03d}",
"x": float(point[0]),
"y": float(point[1]),
"z": float(point[2]),
"nx": float(normal[0]),
"ny": float(normal[1]),
"nz": float(normal[2]),
"u_px": record.get("u_px"),
"v_px": record.get("v_px"),
"pose": {
"x": float(move_x),
"y": float(move_y),
"yaw": float(pose.yaw_deg),
"pitch": float(pose.pitch_deg),
"z": float(pose.contact_z_mm),
"residual_deg": float(pose.residual_deg),
"upright": bool(pose.upright),
"in_range": bool(pose.ok and pose.in_range),
"ok": bool(pose.ok),
"normal_aligned_to": f"tool_axis:{self._stack.tool_axis}",
"reason": pose.reason,
},
})
return points
def export_sites(self, path: str | Path) -> Path:
"""Export the displayed order, with the same UV convention as calibration."""
flat = self.view.flat
if flat is None or not self._picked:
raise ValueError(t("finger.export_no_points"))
origin_x, origin_y = uv_origin(flat)
identity = mapping_sha256(flat)
rows = []
for order, record in enumerate(self._picked, 1):
site = site_at(
flat, record["u_px"], record["v_px"], origin=(origin_x, origin_y),
mapping_digest=identity,
)
point = [record[key] for key in ("x", "y", "z")]
if not np.allclose(point, site.position_sensor_mm, rtol=0, atol=1e-9):
raise ValueError(t("finger.export_edited_point").format(order=order))
rows.append({
"order": order, "site_id": f"site-{order:03d}",
"u_px": site.u_px, "v_px": site.v_px,
**{f"{axis}_sensor_mm": record[axis] for axis in ("x", "y", "z")},
**{f"{axis.lower()}_platform_mm": record[axis] for axis in ("X", "Y", "Z")},
"yaw_deg": record["yaw"], "pitch_deg": record["pitch"],
"origin_raster_x_px": origin_x, "origin_raster_y_px": origin_y,
"raster_width_px": flat.width, "raster_height_px": flat.height,
"u_positive": "right", "v_positive": "up", "mapping_sha256": identity,
})
destination = Path(path)
with destination.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
return destination
def _export_sites_dialog(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, t("finger.export_sites"), "calibration-sites.csv", "CSV (*.csv)"
)
if path:
try:
self.export_sites(path)
self.status_label.setText(t("finger.sites_exported").format(path=path))
except (OSError, ValueError) as exc:
self.status_label.setText(t("calibration.error").format(detail=exc))
return [] if self._point is None else [dict(self._point)]
# ------------------------------------------------------------------ actions
@@ -947,7 +795,8 @@ class FingerPanel(QGroupBox):
"""Turn a click into a pose. Returns the row, or ``None`` if no surface.
``None`` is the honest answer for a UV-island gap: reporting (0, 0, 0)
would put a calibration point at the sensor's origin.
would put a calibration point at the sensor's origin. A second click
replaces the first: this screen tries one point at a time.
"""
if self.view.flat is None:
self.status_label.setText(t("finger.no_bundle"))
@@ -960,9 +809,10 @@ class FingerPanel(QGroupBox):
point, normal, face = sample
origin_x, origin_y = uv_origin(self.view.flat)
pose = self._stack.solve(point, normal)
# The click leaves a cross-hair where it landed *and* promotes the new row
# to the selection, so the red dot marks the same point in both views.
# The click leaves a cross-hair where it landed and a red dot on the point
# about to be tried, so both views name the same pixel.
self.view.set_marker((x, y))
self.view.set_selected((x, y))
self.preview.set_stack(self._stack)
self.preview.set_point(point, normal)
@@ -975,7 +825,7 @@ class FingerPanel(QGroupBox):
"residual": float(pose.residual_deg),
"u_px": x - origin_x, "v_px": origin_y - y,
}
self._append_row(row)
self._point = dict(row)
self._last_pose = pose
self._last_face = face
# Hand the solved pose to the preview so the play button demonstrates
@@ -985,266 +835,8 @@ class FingerPanel(QGroupBox):
self._sync_sim_button()
self.refresh_readouts(point, normal, pose, face)
self._refresh_path()
# Select the row just added, so the table agrees with the dot.
self._writing = True
try:
self.table.setCurrentCell(self.table.rowCount() - 1, 1)
finally:
self._writing = False
return row
def _append_row(self, row: dict[str, float]) -> None:
# The full-precision row is kept alongside the table, because the table
# only holds three decimals for reading. Recovering the numbers by parsing
# the displayed text would quietly hand a later export coordinates the
# solver never produced.
self._picked.append(dict(row))
self.table.insertRow(self.table.rowCount())
self._write_row(self.table.rowCount() - 1)
while self.table.rowCount() > MAX_POINTS:
self.table.removeRow(0)
while len(self._picked) > MAX_POINTS:
self._picked.pop(0)
self._refresh_count()
# ------------------------------------------------------- table row plumbing
def _write_row(self, index: int) -> None:
"""Render one ``_picked`` row into the table, without re-triggering edits."""
if not (0 <= index < len(self._picked)):
return
row = self._picked[index]
self._writing = True
try:
for offset, key in enumerate(self._headers):
column = offset + 1
item = self.table.item(index, column)
if item is None:
item = QTableWidgetItem()
item.setTextAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
if offset >= self.INPUT_COLUMNS:
# Solved values are shown, not typed: editing the normal and
# reading the pose is the workflow, and the other way round
# would need an inverse the table cannot express.
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
item.setForeground(QColor("#5c6678"))
self.table.setItem(index, column, item)
item.setText(
f"{row[key]:.0f}" if key in {"u_px", "v_px"} else f"{row[key]:.3f}"
)
self._ensure_selector(index)
finally:
self._writing = False
def _ensure_selector(self, index: int, checked: bool = False) -> None:
"""Give a row its select checkbox, created once."""
if self.table.cellWidget(index, self.SELECT_COLUMN) is not None:
return
holder = QWidget(self.table)
layout = QHBoxLayout(holder)
layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
check = QCheckBox(holder)
check.setChecked(checked)
check.toggled.connect(self._refresh_count)
layout.addWidget(check)
self.table.setCellWidget(index, self.SELECT_COLUMN, holder)
def _selector(self, index: int) -> QCheckBox | None:
holder = self.table.cellWidget(index, self.SELECT_COLUMN)
if holder is None:
return None
return holder.findChild(QCheckBox)
def selected_rows(self) -> list[int]:
"""Rows whose checkbox is ticked, ascending."""
return [
index
for index in range(self.table.rowCount())
if (check := self._selector(index)) is not None and check.isChecked()
]
def remove_selected(self) -> int:
"""Drop every ticked row, and the last-picked point if none is ticked."""
rows = self.selected_rows()
if not rows:
row = self.table.currentRow()
rows = [row] if row >= 0 else []
for index in sorted(rows, reverse=True):
self.table.removeRow(index)
if 0 <= index < len(self._picked):
self._picked.pop(index)
self._refresh_count()
self._after_points_changed()
return len(rows)
def move_selected(self, offset: int) -> bool:
"""Shift the ticked rows (or the current one) up or down one place.
Moves the topmost row first when going down and the bottom-most first when
going up, so a contiguous block keeps its internal order and no row is
swapped twice. The current row follows the move, so repeating the action
keeps pushing the same point rather than stranding the selection.
"""
rows = self.selected_rows() or (
[self.table.currentRow()] if self.table.currentRow() >= 0 else []
)
if not rows:
return False
ordered = sorted(rows, reverse=offset > 0)
moved = False
landed = self.table.currentRow()
for index in ordered:
target = index + offset
if not (0 <= target < self.table.rowCount()):
continue
if target in rows: # a selected neighbour will move too; do not swap twice
continue
self._swap_rows(index, target)
if index == self.table.currentRow():
landed = target
moved = True
if moved:
self._writing = True
try:
self.table.setCurrentCell(landed, max(1, self.table.currentColumn()))
finally:
self._writing = False
self._refresh_count()
self._after_points_changed()
return moved
def _swap_rows(self, first: int, second: int) -> None:
"""Exchange two table rows and their stored points, selection included."""
self._picked[first], self._picked[second] = self._picked[second], self._picked[first]
checked = [
bool((check := self._selector(row)) is not None and check.isChecked())
for row in (first, second)
]
self._writing = True
try:
# Rebuild both rows from the swapped data: simpler than moving items, and
# it cannot leave a stale value behind. ``takeCellWidget`` does not exist,
# so the old checkboxes are detached by replacing them with None.
for row in (first, second):
for column in range(1, len(self._headers) + 1):
self.table.takeItem(row, column)
self.table.removeCellWidget(row, self.SELECT_COLUMN)
self._write_row(first)
self._write_row(second)
finally:
self._writing = False
for row, was_checked in zip((first, second), checked):
check = self._selector(row)
if check is not None:
check.setChecked(was_checked)
def _refresh_count(self) -> None:
total = self.table.rowCount()
chosen = len(self.selected_rows())
self.count_label.setText(t("finger.point_count", count=total))
self.remove_button.setEnabled(total > 0)
self.up_button.setEnabled(total > 0)
self.down_button.setEnabled(total > 0)
self._selected_label.setText(
t("finger.selected_count", count=chosen) if chosen else ""
)
# ----------------------------------------------------------------- editing
def _on_item_changed(self, item: QTableWidgetItem) -> None:
"""An operator edited a cell: re-solve the row and refresh everything.
Only the six input columns are editable, so an edit always means "this
sensor-frame point or normal changed". The pose columns are redrawn from the
new solve rather than trusted, which is what keeps the table and the readouts
from disagreeing.
"""
if self._writing:
return
column = item.column() - 1
row = item.row()
if column < 0 or column >= self.INPUT_COLUMNS:
return
if not (0 <= row < len(self._picked)):
return
try:
value = float(item.text())
except ValueError:
# Put the old number back rather than storing a non-number.
self._write_row(row)
self.status_label.setText(t("finger.edit_not_a_number"))
return
if not math.isfinite(value):
self._write_row(row)
self.status_label.setText(t("finger.edit_not_a_number"))
return
record = self._picked[row]
record[self._headers[column]] = value
self.resolve_row(row)
self._after_points_changed()
def resolve_row(self, row: int) -> None:
"""Re-solve a stored row from its sensor-frame point and normal."""
if not (0 <= row < len(self._picked)):
return
record = self._picked[row]
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
length = float(np.linalg.norm(normal))
if length < 1e-12:
return
normal = normal / length
pose = self._stack.solve(point, normal)
record.update(
{
"nx": float(normal[0]), "ny": float(normal[1]), "nz": float(normal[2]),
"X": float(pose.target_xy_mm[0]), "Y": float(pose.target_xy_mm[1]),
"Z": float(pose.contact_z_mm),
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
"residual": float(pose.residual_deg),
}
)
self._write_row(row)
def _on_selection_changed(self) -> None:
"""Follow the current row in both pictures, so the marker means something."""
row = self.table.currentRow()
if not (0 <= row < len(self._picked)):
return
self.show_point(row)
def show_point(self, row: int) -> None:
"""Point the 2D and 3D markers at one row: this is the point to be tested."""
if not (0 <= row < len(self._picked)):
return
record = self._picked[row]
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
self.preview.set_point(point, normal)
self.preview.set_highlight(point)
if self.view.flat is not None:
pixel = self.view.pixel_for_point(point)
self.view.set_selected(pixel)
pose = self._stack.solve(point, normal)
self._last_pose = pose
self.preview.set_pose(pose)
self._sync_sim_button()
self.refresh_readouts(point, normal, pose)
self._refresh_path()
def _after_points_changed(self) -> None:
"""Keep the picture honest after a delete, reorder or edit."""
row = self.table.currentRow()
if 0 <= row < len(self._picked):
self.show_point(row)
else:
self.preview.set_highlight(None)
self.view.set_selected(None)
# ----------------------------------------------------------------- readouts
_last_pose = None
@@ -1417,7 +1009,6 @@ class FingerPanel(QGroupBox):
self._picker_box.setTitle(t("finger.flat_title"))
self._readout_box.setTitle(t("finger.readout_title"))
self._preview_box.setTitle(t("finger.preview_title"))
self._table_box.setTitle(t("finger.points_title"))
labels = {
"sensor_point": "finger.row_sensor_point",
"sensor_normal": "finger.row_sensor_normal",
@@ -1456,16 +1047,5 @@ class FingerPanel(QGroupBox):
self.stop_button.setToolTip(t("move.stop_hint"))
self.confirm_check.setText(t("move.confirm_check"))
self.confirm_check.setToolTip(t("move.confirm_hint"))
self.table.setHorizontalHeaderLabels(
[""] + [t(f"finger.col_{key}") for key in self._headers]
)
self.clear_button.setText(t("finger.clear_points"))
self.export_sites_button.setText(t("finger.export_sites"))
self.export_sites_button.setToolTip(t("finger.export_sites_tip"))
self.remove_button.setText(t("finger.remove_selected"))
self.up_button.setText(t("finger.move_up"))
self.down_button.setText(t("finger.move_down"))
self.table.setToolTip(t("finger.table_hint"))
self._refresh_count()
self.status_label.setText(t("finger.select_hint"))
@@ -0,0 +1,124 @@
"""The operator's point sheet: one CSV row per queue point.
This is the file an operator reproduces by hand at the UV map: the pixel to click,
the sensor-frame position behind it, the platform target the simulated mounting
produces, and the mapping identity that makes the pixel coordinates reproducible.
It is an *operation sheet*, not an executed sequence.
The field list is one definition because two readers depend on it: the exporter
below and the test that checks it against
``apps/autocal/docs/calibration-data-format.zh.md``. A second copy of the names is
how the table and the document drift apart.
The platform columns are the point's stored solved pose — the same numbers the
align move commands — so the sheet says what the machine would do, not a second
solution computed for the sheet.
"""
from __future__ import annotations
import csv
from pathlib import Path
import numpy as np
from orisys_app_shared.i18n import t
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
#: Column order of the operation table. First block is the pixel, second the
#: sensor-frame position, third the platform target, last the mapping identity —
#: the same grouping the data-format document uses.
OPERATION_TABLE_FIELDS: tuple[str, ...] = (
"order",
"site_id",
"u_px",
"v_px",
"x_sensor_mm",
"y_sensor_mm",
"z_sensor_mm",
"x_platform_mm",
"y_platform_mm",
"z_platform_mm",
"yaw_deg",
"pitch_deg",
"origin_raster_x_px",
"origin_raster_y_px",
"raster_width_px",
"raster_height_px",
"u_positive",
"v_positive",
"mapping_sha256",
)
#: One pixel of the 1024-wide production raster is about 0.0147 mm, so a gap
#: larger than this means the recorded point and the map no longer agree.
_POINT_MATCH_MM = 1e-9
def operation_table_rows(rows, flat: FlatSurface) -> list[dict]:
"""One dict per queue row, in queue order, ready for :data:`OPERATION_TABLE_FIELDS`.
``rows`` are the controller's ``SiteRow`` snapshots. A row without UV pixels,
without a solved pose, or whose recorded sensor position no longer matches the
current map refuses the export: an operation sheet that points at a pixel the
record does not describe is worse than no sheet.
"""
if flat is None:
raise ValueError(t("cap.queue.export_no_map"))
origin_x, origin_y = uv_origin(flat)
identity = mapping_sha256(flat)
records: list[dict] = []
for order, row in enumerate(rows, 1):
if row.uv_px is None:
raise ValueError(t("cap.queue.export_missing_uv").format(order=order))
if row.pose is None:
raise ValueError(t("cap.queue.export_no_pose").format(order=order))
site = site_at(
flat,
int(row.uv_px[0]),
int(row.uv_px[1]),
origin=(origin_x, origin_y),
mapping_digest=identity,
)
recorded = np.asarray(
[float(row.position[axis]) for axis in "xyz"], dtype=np.float64
)
if not np.allclose(recorded, site.position_sensor_mm, rtol=0, atol=_POINT_MATCH_MM):
raise ValueError(t("cap.queue.export_edited_point").format(order=order))
pose = row.pose
records.append(
{
"order": order,
"site_id": row.site_id,
"u_px": site.u_px,
"v_px": site.v_px,
**{f"{axis}_sensor_mm": float(row.position[axis]) for axis in "xyz"},
**{
f"{axis}_platform_mm": float(pose[axis])
for axis in ("x", "y", "z")
},
"yaw_deg": float(pose["yaw"]),
"pitch_deg": float(pose["pitch"]),
"origin_raster_x_px": origin_x,
"origin_raster_y_px": origin_y,
"raster_width_px": flat.width,
"raster_height_px": flat.height,
"u_positive": "right",
"v_positive": "up",
"mapping_sha256": identity,
}
)
return records
def write_operation_table(path: str | Path, rows, flat: FlatSurface) -> Path:
"""Write the operation table as UTF-8 CSV with the frozen field order."""
if not rows:
raise ValueError(t("cap.queue.export_no_points"))
records = operation_table_rows(rows, flat)
destination = Path(path)
with destination.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(handle, fieldnames=list(OPERATION_TABLE_FIELDS))
writer.writeheader()
writer.writerows(records)
return destination
+75 -21
View File
@@ -11,8 +11,6 @@ from __future__ import annotations
import logging
import math
import subprocess
import sys
from pathlib import Path
import numpy as np
@@ -74,6 +72,7 @@ from .bus_panel import default_bas_dir
from .calibration_panel import CalibrationPanel
from .device_setup import DeviceSetupDialog
from .finger_source_panel import FingerSourcePanel
from .reveal import reveal
from .sensor_panel import FingerPanel
from .sequence_panel import SequencePanel
from .status_strip import RunBar, StatusStrip
@@ -143,6 +142,10 @@ class AutoCalMainWindow(QMainWindow):
self._last_snapshot = Snapshot()
self._pending: dict[int, str] = {}
self._last_error = ""
#: Whether the move in flight was requested by the capture page, so that the
#: page can be told the alignment finished or did not. A move the finger page
#: asked for must never mark a capture point as aligned.
self._capture_alignment = False
# Apply the remembered language before anything is built. The translator
# is process-global, and only the menu ever set it, so a saved language
@@ -308,7 +311,13 @@ 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 = CalibrationPanel(
self.finger_source_panel,
body,
prefs=self.prefs,
solver=self.finger_stack,
bus_problem=self._capture_bus_problem,
)
self.calibration_panel.loadBundleRequested.connect(self._on_load_bundle)
self.workspace = WorkspaceTabs(
self.axis_panel,
@@ -381,8 +390,7 @@ class AutoCalMainWindow(QMainWindow):
self.finger_panel.executeRequested.connect(self._on_execute_pose)
self.finger_panel.stopRequested.connect(self._on_stop_all)
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
self.calibration_panel.multipoint.bind_finger(self.finger_panel)
self.calibration_panel.multipoint.alignRequested.connect(self._on_align_multipoint)
self.calibration_panel.alignRequested.connect(self._on_align_multipoint)
self.run_bar.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort)
@@ -416,6 +424,20 @@ class AutoCalMainWindow(QMainWindow):
if problem:
self.finger_panel.set_move_status(f"✗ {problem}")
def _capture_bus_problem(self) -> str:
"""Why the capture page's bus check fails, or ``""`` when it passes.
The capture page may only start on a connected controller with an
initialised bus and a sound axis mapping; the page renders this answer and
does not read the service itself.
"""
service = self.poller.service
if not service.connected:
return "the controller is not connected"
if not service.bus.initialized:
return "the bus is not initialised"
return service.roles_problem()
@property
def _press_axis(self) -> PressAxis:
"""How the press axis's coordinate relates to a contact height.
@@ -443,7 +465,7 @@ class AutoCalMainWindow(QMainWindow):
export, a missing file or an unreadable mesh must leave the rest of the
app alone and report the reason.
"""
if self.calibration_panel.trial.session is not None:
if self.calibration_panel.session_open:
raise ValueError(t("calibration.close_before_mapping"))
bundle = load_bundle(
path,
@@ -552,6 +574,10 @@ class AutoCalMainWindow(QMainWindow):
self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm)
self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg
self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm)
# The queue's poses were solved with the machinery that just changed, so the
# capture page re-solves them: writes back while idle, display-only when the
# queue is frozen between trials.
self.calibration_panel.resolve_queue()
# ---------------------------------------------------------------- run bar
@@ -833,6 +859,7 @@ class AutoCalMainWindow(QMainWindow):
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
self.mover.failed.connect(self._on_move_failed)
self.mover.finished.connect(self._on_move_finished)
self.mover.cancelled.connect(self._on_move_cancelled)
# Ages the poll-health chip even when no snapshot arrives. Runs on the
# GUI thread and only reads a timestamp — the controller is untouched —
@@ -1099,6 +1126,7 @@ class AutoCalMainWindow(QMainWindow):
self._pending[token] = ""
def _on_jog(self, axis: int, forward: bool) -> None:
self._capture_platform_moved()
token = self.poller.submit(lambda service: service.jog(axis, forward))
self._pending[token] = ""
@@ -1153,11 +1181,16 @@ class AutoCalMainWindow(QMainWindow):
self._start_move(plan, t("move.execute"))
def _on_align_multipoint(self, targets: dict) -> None:
"""Same five-axis move as the finger screen, for the point chosen on 选择测点.
"""Same five-axis move as the finger screen, for the current capture point.
The calibration panel hands over geometry, not controller coordinates, so
The calibration page hands over geometry, not controller coordinates, so
the press-axis relation is applied here as well — one place decides which
roles a solved point may command, whichever screen asked for the move.
The page is told the outcome: only a move that finished may mark the point
aligned, so a refused, cancelled or failed move leaves "start capture"
unclickable. That report is why ``_capture_alignment`` exists: a move the
finger screen asked for must never mark a capture point aligned.
"""
targets, held = press_targets(targets, self._press_axis)
plan = plan_pose_move(
@@ -1167,12 +1200,27 @@ class AutoCalMainWindow(QMainWindow):
hold=held,
together=True,
)
self._capture_alignment = True
self._start_move(
plan,
t("multipoint.align"),
report=self.calibration_panel.multipoint.message.setText,
report=self.calibration_panel.set_message,
)
def _capture_platform_moved(self) -> None:
"""A move the capture page did not ask for: its "aligned" no longer holds."""
self.calibration_panel.alignment_invalidated()
def _capture_move_outcome(self, ok: bool, reason: str = "") -> None:
"""Tell the capture page how the move it asked for ended, once."""
if not self._capture_alignment:
return
self._capture_alignment = False
if ok:
self.calibration_panel.alignment_succeeded()
else:
self.calibration_panel.alignment_failed(reason)
def _on_return_to_initial(self) -> None:
"""Yaw, pitch and Z back to zero. Table X/Y park at the stored initial."""
@@ -1201,6 +1249,7 @@ class AutoCalMainWindow(QMainWindow):
if refusal is not None:
say(refusal.message)
self._refuse_move(refusal.message)
self._capture_move_outcome(False, refusal.message)
return
# Confirm before the platform moves, with the numbers rather than a summary of
# them: this is the operator's chance to notice that the simulation and the
@@ -1236,12 +1285,16 @@ class AutoCalMainWindow(QMainWindow):
if answer != QMessageBox.StandardButton.Yes:
say(t("move.cancelled"))
self.finger_panel.set_move_status(t("move.cancelled"))
self._capture_move_outcome(False, t("move.cancelled"))
return
if not self.mover.start(plan).ok:
reason = self.mover.state.failure or t("move.busy")
say(reason)
self._refuse_move(reason)
self._capture_move_outcome(False, reason)
return
if not self._capture_alignment:
self._capture_platform_moved()
self.finger_panel.set_move_status(plan.describe())
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
@@ -1258,6 +1311,7 @@ class AutoCalMainWindow(QMainWindow):
if plan is not None:
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
self.status_strip.set_action_result(t("move.done"), True)
self._capture_move_outcome(True)
def _on_move_failed(self, message: str) -> None:
# A mover that gave up leaves an axis wherever it stopped: stop the rest
@@ -1265,15 +1319,25 @@ class AutoCalMainWindow(QMainWindow):
self.finger_panel.set_move_status(f"✗ {message}")
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
self._capture_move_outcome(False, message)
self.poller.submit(lambda service: service.stop_all())
def _on_move_cancelled(self) -> None:
"""The operator stopped a move: the axes stay where they stopped."""
self.finger_panel.set_move_status(t("move.cancelled"))
self.status_strip.set_action_result(t("move.cancelled"), False)
self._capture_move_outcome(False, t("move.cancelled"))
def _on_move_abs(self, axis: int, target: float) -> None:
self._capture_platform_moved()
self.poller.submit(lambda service: service.move_abs(axis, target))
def _on_move_rel(self, axis: int, distance: float) -> None:
self._capture_platform_moved()
self.poller.submit(lambda service: service.move_rel(axis, distance))
def _on_home(self, axis: int) -> None:
self._capture_platform_moved()
self.poller.submit(lambda service: service.home(axis))
def _on_clear_alarm(self, axis: int) -> None:
@@ -1353,20 +1417,10 @@ class AutoCalMainWindow(QMainWindow):
def _on_reveal_config(self) -> None:
from orisys_app_shared.paths import config_dir
self._reveal(config_dir())
reveal(config_dir())
def _on_reveal_outputs(self) -> None:
self._reveal(outputs_dir())
@staticmethod
def _reveal(path: Path) -> None:
try:
if sys.platform == "win32":
subprocess.Popen(["explorer", str(path)])
else: # pragma: no cover - the platform is Windows
subprocess.Popen(["xdg-open", str(path)])
except OSError:
logger.exception("Could not open %s", path)
reveal(outputs_dir())
def _on_about(self) -> None:
QMessageBox.information(
+34 -9
View File
@@ -290,6 +290,7 @@ def _write_multipoint_trial(
raw_per_n: float,
samples_per_hold: int,
frame_start: int,
capture_status: str | None = None,
) -> list[dict]:
write_csv(
path / "reference.csv",
@@ -302,14 +303,17 @@ def _write_multipoint_trial(
["t_host_mono_ns", "t_host_utc_ns", "kind", "detail"],
[],
)
write_json_file(path / "session.json", {
manifest = {
"trial_id": f"{site_id}-repeat-{repeat_index:03d}",
"site_id": site_id,
"status": "complete",
"uv_px": {"u": 128, "v": 64},
"images": {"captured": with_capture,
"reason": "" if with_capture else "camera not attached yet"},
})
}
if capture_status is not None:
manifest["capture_status"] = capture_status
write_json_file(path / "session.json", manifest)
if not with_capture:
return []
frames: list[dict] = []
@@ -324,8 +328,13 @@ def _write_multipoint_trial(
)
frames.extend(hold_frames)
forces.extend(hold_forces)
write_capture(path / "capture", frames, forces, fields=None if video else FRAMES_FIELDS,
video_segments=1)
if not video:
# A data-only recording: the rows exist, no frame was written to a video.
frames = [
{**row, "segment": 0, "segment_frame": 0, "in_video": 0} for row in frames
]
write_capture(path / "capture", frames, forces, fields=FRAMES_FIELDS,
video_segments=1 if video else 0)
return []
@@ -343,8 +352,17 @@ def multipoint_session(
simulate: bool = False,
trial_status: str = "complete",
extra_holds: list[Hold] | None = None,
recording_block: dict | None = None,
capture_status: str | None = None,
) -> Path:
"""A complete multi-point session directory through the review contract."""
"""A complete multi-point session directory through the review contract.
``video=False`` writes a data-only recording: frame rows exist with
``in_video=0`` and no MP4 is created. ``recording_block`` writes the root
manifest's ``recording`` block; ``None`` keeps the fixture shaped like a
session recorded before that block existed. ``capture_status`` puts the
lifecycle into the trial records, as the campaign does.
"""
session_id = f"finger-SN1_{root.name}_test0001"
trials: list[dict] = []
completion: list[dict] = []
@@ -379,21 +397,25 @@ def multipoint_session(
raw_per_n=raw_per_n,
samples_per_hold=samples_per_hold,
frame_start=1,
capture_status=capture_status,
)
trials.append({
trial_record = {
"trial_id": f"{site_id}-repeat-{repeat:03d}",
"site_id": site_id,
"repeat_index": repeat,
"directory": directory,
"status": trial_status,
"reason": "" if trial_status == "complete" else "stopped by the operator",
})
}
if capture_status is not None:
trial_record["capture_status"] = capture_status
trials.append(trial_record)
if trial_status == "complete":
complete += 1
completion.append({"site_id": site_id, "repeats_planned": repeats,
"repeats_complete": complete,
"complete": complete >= repeats})
write_json_file(root / "session.json", {
manifest = {
"format_version": "autocal-multipoint-1.0",
"session_id": session_id,
"sensor_model": "finger",
@@ -411,7 +433,10 @@ def multipoint_session(
"[t_start_host_mono_ns, t_end_host_mono_ns). Rows are not paired."},
"platform_motion": False,
"automatic_force_control": False,
})
}
if recording_block is not None:
manifest["recording"] = recording_block
write_json_file(root / "session.json", manifest)
return root
+10
View File
@@ -34,6 +34,14 @@ FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed, and where it may be named.
ALLOWED_BINDING = "PySide6"
#: ``ui/`` modules that deliberately name no binding. ``reveal.py`` hands a path
#: to the OS file manager; ``site_export.py`` writes the queue's CSV point sheet.
#: Neither builds a widget, so neither has a binding to declare. The rule below
#: exists so a UI module cannot *use* a binding it does not name; a module that
#: uses none cannot break it, and importing PySide6 only to satisfy the rule would
#: be the false declaration the rule is meant to prevent.
_BINDING_FREE_UI = {"reveal.py", "site_export.py"}
#: Scoped enum holders we care about, with the value names actually used.
#:
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
@@ -147,6 +155,8 @@ def test_every_ui_module_imports_the_allowed_binding():
for path in _python_files(APP_SRC / "orisys_autocal" / "ui"):
if path.name == "__init__.py":
continue # a re-export barrel, not a widget module
if path.name in _BINDING_FREE_UI:
continue
if ALLOWED_BINDING not in _imported_packages(path):
offenders.append(path.name)
assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}"
File diff suppressed because it is too large Load Diff
+736
View File
@@ -0,0 +1,736 @@
"""The capture page end to end: one queue, one primary button, one message line.
These are interface tests in the literal sense: they click the widgets an operator
clicks, in the order the controller offers them, and check the files the session
wrote. The platform move is stubbed at the window's move planner (the real move is
hardware-adjacent and already covered elsewhere); everything else runs as shipped.
"""
from __future__ import annotations
import csv
import json
import re
import time
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_app_shared.i18n import t
from orisys_autocal.sensor.uv_flat import FlatSurface
POINT = (4.872, 5.444, 16.176)
NORMAL = (0.595, 0.673, 0.439)
#: The only direct Campaign calls the frozen contract keeps in ``ui/``: the
#: recorder's lifecycle is not a workflow decision and not rendered data.
_ALLOWED_CAMPAIGN_MEMBERS = frozenset({"attach_capture", "flush_close"})
def _campaign_accesses(source: str) -> list[str]:
"""Every ``campaign.<member>`` access in one module's source, in order."""
return [match.group(1) for match in re.finditer(r"campaign\.(\w+)", source)]
def _campaign_offenders(directory) -> list[str]:
"""UI modules reaching past the controller for campaign state or actions."""
offenders = []
for path in sorted(directory.rglob("*.py")):
for member in _campaign_accesses(path.read_text(encoding="utf-8")):
if member not in _ALLOWED_CAMPAIGN_MEMBERS:
offenders.append(f"{path.name}: campaign.{member}")
return offenders
def _flat() -> FlatSurface:
"""A stand-in rasterised surface: every pixel is the same measured point."""
size = 8
positions = np.tile(np.asarray(POINT, dtype=np.float64), (size, size, 1))
normals = np.tile(np.asarray(NORMAL, dtype=np.float64), (size, size, 1))
return FlatSurface(
size_hw=(size, size),
image_rgb=np.zeros((size, size, 3), dtype=np.uint8),
positions_mm=positions,
normals=normals,
face_index=np.zeros((size, size), dtype=np.int32),
uv_min=(0.0, 0.0),
uv_max=(1.0, 1.0),
)
def _pump(app, seconds: float = 0.3) -> None:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
def _page(
window,
qt_app,
tmp_path,
*,
max_force: str = "20",
align: bool = True,
flat: bool = True,
):
"""A capture page whose readiness passes in simulation mode.
``align=True`` (the default) makes the page behave as it does with a platform
attached: the point must be aligned before a session may start, so the tests
that are about the align step keep covering it. ``align=False`` is the page as
it is with nothing connected, and ``flat=False`` leaves out the calibration
bundle too.
"""
window.resize(1440, 900)
window.show()
window.workspace.set_current_key("sensor")
qt_app.processEvents()
page = window.calibration_panel
page.settings.simulate.setChecked(True)
page.settings.sensor_sn.setText("SN1")
page.settings.max_force.setText(max_force)
page.settings.output_path.setText(str(tmp_path))
if align:
# The page treats a simulation as needing no move; these tests are about
# the step that does, so they ask for the platform-attached behaviour.
page._simulating = lambda: False # noqa: SLF001 - the seam under test
if flat:
page.set_flat(_flat(), "bundle")
# A hold of a few hundredths of a second keeps the test fast; the session
# contract is the same.
page.hold.setRange(0.05, 30)
page.hold.setValue(0.05)
page.levels.setText("5")
page.repeats.setValue(1)
return page
def _started_page(window, qt_app, tmp_path, *, sites=("P1", "P2")):
"""Two points added through the page, first aligned, session started."""
_stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path)
for index, site_id in enumerate(sites):
_add_point(page, site_id, index, index, qt_app)
_click_primary(page, qt_app) # align the first point
assert page.controller.primary_action().key == "start_session"
_click_primary(page, qt_app) # start the session
assert page.controller.poll()["phase"] == "load"
return page
def _stub_moves(window, *, succeeds: bool = True) -> list[dict]:
"""Replace the window's move planner: record the plan, report the outcome."""
plans: list[dict] = []
def fake_start(plan, title, *, report=None):
plans.append({"title": title, "roles": [step.role for step in plan.steps]})
if succeeds:
window.calibration_panel.alignment_succeeded()
else:
window.calibration_panel.alignment_failed("refused by the test")
window._start_move = fake_start # noqa: SLF001 - the seam under test is the page
return plans
def _click_primary(page, qt_app, timeout: float = 5.0) -> str:
"""Press the primary button once, after filling whatever it needs first."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
qt_app.processEvents()
action = page.controller.primary_action()
if action.enabled and not (
action.key == "confirm_hold" and page.controller.poll()["remaining_s"] > 0
):
# The page redraws on its own timer; read the button the operator would
# click, not just the controller's answer, or a fast step races the redraw.
page._refresh() # noqa: SLF001 - the widget state is what is under test
if not page.primary_button.isEnabled():
_pump(qt_app, 0.05)
continue
if action.key == "confirm_target":
target = page.controller.model().progress.get("target_n") or 0.0
page.reference.setText(f"{float(target):g}")
page.primary_button.click()
_pump(qt_app, 0.1)
return action.key
_pump(qt_app, 0.05)
raise AssertionError(
f"the primary action never became available: "
f"{page.controller.primary_action()} / {page.message.text()}"
)
def _add_point(page, site_id: str, u: int, v: int, qt_app) -> None:
page.u_spin.setValue(u)
page.v_spin.setValue(v)
page.site_id.setText(site_id)
page.add_point_button.click()
_pump(qt_app, 0.05)
def test_two_point_capture_walkthrough_in_simulation(make_window, qt_app, tmp_path):
"""Align, start, load every step, hold, liftoff, align, and finish - by clicking."""
window = make_window(move_confirm=False)
plans = _stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 1, 1, qt_app)
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
# The first point must be aligned before the session may start.
assert page.controller.primary_action().key == "align_point"
assert t("cap.action.align") == page.primary_button.text()
_click_primary(page, qt_app)
# The press axis is held because its relation to a contact height is not
# calibrated on the test bench; the four movable roles still travel together.
assert plans and plans[-1]["roles"] == ["yaw", "pitch", "x", "y"]
assert page.controller.primary_action().key == "start_session"
assert t("cap.action.start") == page.primary_button.text()
_click_primary(page, qt_app) # start the session
state = page.controller.poll()
assert state["phase"] == "load"
# Starting a capture does not switch the workspace page.
assert window.workspace.current_key() == "sensor"
# The recording options are locked once the session exists.
assert not page.settings.video_check.isEnabled()
assert not page.settings.locked_label.isHidden()
assert page.settings.locked_label.text() == t("cap.recording.locked")
seen: list[str] = []
for _ in range(60):
action = page.controller.primary_action()
seen.append(action.key)
if action.key == "finish_session":
_click_primary(page, qt_app)
break
_click_primary(page, qt_app)
else: # pragma: no cover - a stuck walkthrough is the failure to report
raise AssertionError(f"the session never finished: {seen[-6:]}")
# The hold ends by itself at its planned time, so the button may or may not be
# caught showing "confirm hold" between two ticks; the order of the steps is
# what matters.
assert seen[0] == "confirm_target"
assert seen.count("confirm_target") >= 3
# Two points, so the second one needs its own alignment between them.
assert "align_point" in seen
assert "confirm_liftoff" in seen
directory = page._session_directory() # noqa: SLF001 - the recorded folder under test
assert directory is not None
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["status"] == "complete"
assert [site["site_id"] for site in manifest["sites"]] == ["P1", "P2"]
assert [trial["status"] for trial in manifest["trials"]] == ["complete", "complete"]
assert (directory / "stable_means.csv").is_file()
# A simulated run writes no video; that is the recorded state, not a failure.
assert page.controller.poll()["capture_state"] == "not_recorded"
# The end of the session publishes the candidate summary (P1's flat copy).
deadline = time.monotonic() + 20.0
while not (directory / "summary-latest.json").is_file() and time.monotonic() < deadline:
_pump(qt_app, 0.05)
assert (directory / "summary-latest.json").is_file()
window.close()
def test_a_simulation_runs_with_nothing_connected(make_window, qt_app, tmp_path):
"""No bundle, no platform, no camera: type two points, start, finish, go again."""
window = make_window(move_confirm=False)
plans = _stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path, align=False, flat=False)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 3, 4, qt_app)
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
# Every ready check passes without a bundle, a bus or an input frame.
assert all(check.ok for check in page.controller.readiness()), [
check for check in page.controller.readiness() if not check.ok
]
# No align step stands between the queue and the session.
assert page.controller.primary_action().key == "start_session"
assert t("cap.action.start") == page.primary_button.text()
_click_primary(page, qt_app) # start the session
assert page.controller.poll()["phase"] == "load"
seen: list[str] = []
for _ in range(60):
action = page.controller.primary_action()
seen.append(action.key)
_click_primary(page, qt_app)
if action.key == "finish_session":
break
else: # pragma: no cover - a stuck walkthrough is the failure to report
raise AssertionError(f"the session never finished: {seen[-6:]}")
assert "align_point" not in seen
assert "confirm_liftoff" in seen
assert plans == [] # nothing asked the window to move an axis
directory = page._session_directory() # noqa: SLF001
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["simulate"] is True
assert manifest["status"] == "complete"
assert {site["pose"]["normal_aligned_to"] for site in manifest["sites"]} == {
"simulated"
}
# Closing out leaves the page ready to run the same queue again.
assert page.controller.poll()["phase"] == "idle"
assert page.controller.primary_action().key == "start_session"
window.close()
def test_a_refused_alignment_keeps_start_disabled(make_window, qt_app, tmp_path):
"""A move the operator did not confirm must never unlock "start capture"."""
window = make_window(move_confirm=False)
_stub_moves(window, succeeds=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_click_primary(page, qt_app) # align: the window refuses the move
assert page.controller.primary_action().key == "align_point"
assert page.controller.primary_action().key != "start_session"
assert t("cap.action.start") != page.primary_button.text()
assert t("cap.message.align_failed", detail="refused by the test") in page.message.text()
# A retry whose move is confirmed is what unlocks the session.
_stub_moves(window, succeeds=True)
_click_primary(page, qt_app)
assert page.controller.primary_action().key == "start_session"
window.close()
def test_the_capture_page_never_reaches_past_the_controller():
"""P4 exit condition: ``ui/`` renders controller output, not campaign state.
The guard is a member-access scan rather than a fixed string list: any new
``campaign.<member>`` in a UI module is a finding unless it is one of the two
recorder-lifecycle methods the frozen contract keeps (interfaces 1.11). That
covers ``phase``, ``sites``, ``_*``, every queue edit and every flow action —
including ones added later.
"""
from pathlib import Path
ui = Path(__file__).resolve().parents[1] / "src" / "orisys_autocal" / "ui"
assert _campaign_offenders(ui) == []
def test_the_campaign_guard_catches_a_regression():
"""The guard above must fail on a forbidden access, not merely pass by luck."""
assert _campaign_accesses("x = self.campaign.sites\n") == ["sites"]
assert _campaign_accesses("if self.campaign.phase == 'idle':") == ["phase"]
assert _campaign_accesses("self.campaign._skipped") == ["_skipped"]
assert _campaign_accesses("self.campaign.add_solved_site(a)") == ["add_solved_site"]
assert _campaign_accesses("self.campaign.confirm_reference(1)") == ["confirm_reference"]
assert _campaign_accesses("self.campaign.attach_capture(w)") == ["attach_capture"]
for member in (
"phase",
"sites",
"_skipped",
"add_solved_site",
"update_site",
"remove_site",
"move_site",
"store_solved_pose",
"set_force_rotation",
"confirm_reference",
"complete_hold",
"stop",
"remeasure_step",
"skip_current_point",
):
assert member not in _ALLOWED_CAMPAIGN_MEMBERS, member
def test_unavailable_codecs_are_disabled_with_their_reason(make_window):
"""A codec this machine cannot open is refused here, never silently swapped."""
window = make_window()
settings = window.calibration_panel.settings
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
model = settings.codec.model()
for index in range(settings.codec.count()):
item = model.item(index)
codec = settings.codec.itemData(index)
assert item.isEnabled() is (codec == "mp4v")
assert settings.codec.currentData() == "mp4v"
for codec in ("avc1", "H264"):
assert t("cap.recording.unavailable", codec=codec) in settings.codec_reason.text()
window.close()
def test_the_size_estimate_warns_below_twice_the_planned_volume(make_window, monkeypatch):
from orisys_autocal.ui import capture_settings_dialog as module
window = make_window()
settings = window.calibration_panel.settings
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
settings.video_check.setChecked(True)
monkeypatch.setattr(
module.shutil,
"disk_usage",
lambda path: SimpleNamespace(total=0, used=0, free=1_000_000_000),
)
# One hour of mp4v at the measured bench bitrate is far more than 1 GB.
settings.set_plan(3600.0)
assert t("cap.recording.disk_short") in settings.estimate.text()
assert "12.15 GB" in settings.estimate.text()
monkeypatch.setattr(
module.shutil,
"disk_usage",
lambda path: SimpleNamespace(total=0, used=0, free=10**14),
)
settings.set_plan(3601.0)
assert t("cap.recording.disk_short") not in settings.estimate.text()
# Recording no video makes the size question disappear, not a zero.
settings.video_check.setChecked(False)
assert settings.estimate.text().startswith(t("cap.recording.off"))
window.close()
def test_a_session_recorded_without_video_says_so_in_the_review(qt_app, tmp_path):
from evidence_helpers import multipoint_session
from orisys_autocal.session_review import review_session
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
root = multipoint_session(
tmp_path / "quiet",
sites=("P1",),
repeats=1,
levels=(5.0,),
recording_block={"video": False, "codec": "mp4v", "lossless_frames": False},
)
review = review_session(root)
assert review["recording"]["video"] is False
dialog = RecordOverviewDialog(root, review=review)
try:
assert t("overview.video_not_recorded") in dialog.identity.text()
finally:
dialog.close()
# A session that kept its video says nothing about a missing one.
with_video = multipoint_session(
tmp_path / "loud", sites=("P1",), repeats=1, levels=(5.0,)
)
dialog = RecordOverviewDialog(with_video, review=review_session(with_video))
try:
assert t("overview.video_not_recorded") not in dialog.identity.text()
finally:
dialog.close()
def test_the_page_refuses_start_until_the_ready_checks_pass(make_window, qt_app):
"""Every ✗ check names itself and blocks the start with a stated reason."""
window = make_window(move_confirm=False)
page = window.calibration_panel
window.workspace.set_current_key("sensor")
qt_app.processEvents()
action = page.controller.primary_action()
reason = t(action.blocked_reason)
assert action.enabled is False
assert page.primary_reason.text() == reason
checks = {check.key: check for check in page.controller.readiness()}
assert checks["max_force"].ok is False
assert checks["sensor_sn"].ok is False
for key in ("calibration_bundle", "input_frames", "bus_ready"):
assert checks[key].ok is False
assert page.ready_labels[key].text().startswith("✗")
# Filling one fact turns only that chip over.
page.settings.sensor_sn.setText("SN1")
checks = {check.key: check for check in page.controller.readiness()}
assert checks["sensor_sn"].ok is True
assert page.controller.primary_action().enabled is False
window.close()
def test_a_point_needs_a_maximum_force_and_the_controller_is_told(
make_window, qt_app, tmp_path
):
"""The queue edit is the controller's, including its maximum-force refusal."""
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path, max_force="")
page._refresh() # noqa: SLF001 - the page's own render is the unit under test
page.add_point_button.click()
_pump(qt_app, 0.1)
assert page.controller.model().sites == []
assert page.message.text().endswith(t("cap.block.no_max_force"))
# The dialog value reaches the controller through set_max_force, not a rebuild.
page.settings.max_force.setText("20")
page._refresh() # noqa: SLF001
checks = {check.key: check for check in page.controller.readiness()}
assert checks["max_force"].ok is True
_add_point(page, "P1", 0, 0, qt_app)
assert [row.site_id for row in page.controller.model().sites] == ["P1"]
window.close()
def test_a_skipped_point_is_rendered_from_the_controller_row(
make_window, qt_app, tmp_path
):
"""The skip mark is the controller's; the page only draws ``SiteRow.skipped``."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path)
assert page.controller.model().sites[0].skipped is False
page._refresh() # noqa: SLF001 - render the freshly enabled button
page.skip_button.click()
_pump(qt_app, 0.2)
assert page.controller.model().sites[0].skipped is True
assert page.queue_table.item(0, 4).text() == t("cap.queue.state_skipped")
assert page.message.text().endswith(t("cap.message.skipped", site="P1"))
window.close()
def test_the_stop_button_leaves_the_reason_to_the_controller(
make_window, qt_app, tmp_path
):
"""The stop button passes no reason; the controller records the default one."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
directory = page._session_directory() # noqa: SLF001
page._refresh() # noqa: SLF001 - render the freshly enabled button
page.stop_button.click()
_pump(qt_app, 0.2)
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["reason"] == "operator stopped"
assert page.controller.primary_action().key == "finish_session"
window.close()
def test_closing_the_window_records_the_application_reason(
make_window, qt_app, tmp_path
):
"""Window shutdown names its own reason, so the data can tell the two apart."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
directory = page._session_directory() # noqa: SLF001
window.close()
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["reason"] == "application closed"
def test_updating_a_point_retakes_it_and_keeps_its_queue_position(
make_window, qt_app, tmp_path
):
"""A retaken point goes through the controller and stays where the operator put it."""
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
for index, site_id in enumerate(("P1", "P2", "P3")):
_add_point(page, site_id, index, index, qt_app)
page.queue_table.selectRow(1) # the operator's row selection
qt_app.processEvents()
assert page._selected_site == "P2" # noqa: SLF001
page.u_spin.setValue(5)
page.v_spin.setValue(5)
page.site_id.setText("P2b")
page.update_point_button.click()
_pump(qt_app, 0.2)
rows = page.controller.model().sites
assert [row.site_id for row in rows] == ["P1", "P2b", "P3"]
assert rows[1].site_id == "P2b"
assert rows[1].uv_px is not None
assert page._selected_site == "P2b" # noqa: SLF001
window.close()
# --------------------------------------------------- P5: geometry, sheet, reach
def _drive_to_liftoff(page) -> None:
"""Finish the first point's repeat directly, leaving the session at liftoff."""
campaign = page.controller.campaign
while campaign.phase == "load":
target = campaign.progress()["target_n"]
campaign.confirm_reference(target)
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
)
assert campaign.phase == "liftoff"
def test_changing_the_mounting_resolves_every_queued_pose(make_window, qt_app, tmp_path):
"""stackChanged re-solves the queue and writes the fresh poses back while idle."""
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 1, 1, qt_app)
before = {row.site_id: row.pose["z"] for row in page.controller.model().sites}
spin = window.finger_panel.sensor_z_spin
spin.setValue(spin.value() + 4.0) # the operator changes the mounting height
_pump(qt_app, 0.2)
rows = page.controller.model().sites
assert all(row.reachable for row in rows)
for row in rows:
assert row.pose["z"] != pytest.approx(before[row.site_id]), row.site_id
fresh = page._solve_uv(*row.uv_px)[2] # noqa: SLF001 - the solver's answer
assert row.pose["z"] == pytest.approx(fresh["z"])
window.close()
def test_an_unreachable_point_is_marked_and_cannot_be_aligned(
make_window, qt_app, tmp_path
):
"""A travel limit that makes a point unreachable disables its align, visibly."""
window = make_window(move_confirm=False)
emitted = _stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
assert page.controller.model().sites[0].reachable is True
emitted.clear()
window.finger_panel.stack.yaw_limit_deg = 0.0 # the stage can no longer turn
window.finger_panel.stackChanged.emit()
_pump(qt_app, 0.2)
row = page.controller.model().sites[0]
assert row.reachable is False
assert page.queue_table.item(0, 3).text() == t("cap.queue.unreachable")
action = page.controller.primary_action()
assert (action.key, action.enabled) == ("align_point", False)
assert action.blocked_reason == "cap.block.no_pose"
page._align() # noqa: SLF001 - a direct call must be refused too
assert not emitted
assert t("cap.block.pose_unreachable") in page.message.text()
window.close()
def test_a_frozen_queue_is_not_rewritten_when_the_mounting_changes(
make_window, qt_app, tmp_path
):
"""Between trials the queue is frozen: reachability is shown, poses are not stored."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path)
_drive_to_liftoff(page)
page._refresh() # noqa: SLF001
recorded = page.controller.model().sites[1].pose["z"]
spin = window.finger_panel.sensor_z_spin
spin.setValue(spin.value() + 4.0)
_pump(qt_app, 0.2)
rows = page.controller.model().sites
assert rows[1].pose["z"] == pytest.approx(recorded) # nothing written back
assert page._live_reachable # noqa: SLF001 - the display-only answer exists
fresh = page._solve_uv(*rows[1].uv_px)[2] # noqa: SLF001
assert page._live_reachable[rows[1].site_id] == bool( # noqa: SLF001
fresh["ok"] and fresh["in_range"]
)
window.close()
def test_the_point_sheet_matches_the_documented_fields(make_window, qt_app, tmp_path):
"""P5 exit: the operation table's fields are the data-format document's, in order."""
from orisys_autocal.sensor.uv_flat import mapping_sha256, uv_origin
from orisys_autocal.ui.site_export import OPERATION_TABLE_FIELDS, write_operation_table
document = (
Path(__file__).resolve().parents[1]
/ "docs"
/ "calibration-data-format.zh.md"
).read_text(encoding="utf-8")
# The document spells out the first two blocks; the rest are described as a group.
explicit = (
"order", "site_id", "u_px", "v_px",
"x_sensor_mm", "y_sensor_mm", "z_sensor_mm",
"x_platform_mm", "y_platform_mm", "z_platform_mm",
"yaw_deg", "pitch_deg",
"origin_raster_x_px", "origin_raster_y_px",
)
assert OPERATION_TABLE_FIELDS[: len(explicit)] == explicit
for field in explicit:
assert field in document, f"{field} is not named in the format document"
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 1, 1, qt_app)
target = tmp_path / "points.csv"
write_operation_table(target, page.controller.model().sites, page._flat)
with target.open(encoding="utf-8-sig", newline="") as handle:
header = next(csv.reader(handle))
written = list(csv.DictReader(target.open(encoding="utf-8-sig", newline="")))
assert tuple(header) == OPERATION_TABLE_FIELDS
assert [row["site_id"] for row in written] == ["P1", "P2"]
origin = uv_origin(page._flat)
assert int(written[0]["origin_raster_x_px"]) == origin[0]
assert int(written[0]["origin_raster_y_px"]) == origin[1]
assert int(written[0]["raster_width_px"]) == page._flat.width
assert int(written[0]["raster_height_px"]) == page._flat.height
assert written[0]["u_positive"] == "right"
assert written[0]["v_positive"] == "up"
assert written[0]["mapping_sha256"] == mapping_sha256(page._flat)
for record, row in zip(written, page.controller.model().sites):
assert float(record["x_sensor_mm"]) == pytest.approx(row.position["x"])
assert float(record["x_platform_mm"]) == pytest.approx(row.pose["x"])
assert float(record["yaw_deg"]) == pytest.approx(row.pose["yaw"])
# A recorded coordinate that no longer matches the map refuses the sheet.
page.controller.campaign.sites[0].x += 0.5
with pytest.raises(ValueError):
write_operation_table(tmp_path / "invalid.csv", page.controller.model().sites, page._flat)
assert not (tmp_path / "invalid.csv").exists()
window.close()
def test_the_export_button_writes_the_sheet_and_reports_it(
make_window, qt_app, tmp_path, monkeypatch
):
from orisys_autocal.ui import calibration_panel as module
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
target = tmp_path / "sheet.csv"
monkeypatch.setattr(
module.QFileDialog,
"getSaveFileName",
lambda *args, **kwargs: (str(target), ""),
)
page.export_sheet_button.click()
_pump(qt_app, 0.1)
assert target.is_file()
assert page.message.text() == t("cap.queue.export_saved", path=str(target))
# The refusal path names the point and writes nothing.
page.controller.campaign.sites[0].x += 0.5
page.export_sheet_button.click()
_pump(qt_app, 0.1)
assert t("cap.queue.export_edited_point", order=1) in page.message.text()
window.close()
def test_a_failed_summary_copy_is_reported_not_called_published(make_window):
"""The copy under summary/ is a convenience; a failure must not read as success."""
window = make_window()
page = window.calibration_panel
detail = "the readable copy under summary/ was not written: no space left"
page._summary_warnings = [detail]
page._summary_finished("/some/published/summary", None)
assert page.message.text() == t("summary.copy_failed").format(detail=detail)
window.close()
def test_a_stored_unavailable_codec_does_not_become_the_selection(make_window):
"""Loading stored options must not re-select a codec the probe disabled."""
window = make_window()
settings = window.calibration_panel.settings
settings.prefs["recording"] = {
"video": True,
"codec": "avc1",
"lossless_frames": False,
}
# The dialog probes first and loads the stored options second; that order is
# what used to put the disabled avc1 back into the combo.
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
settings._load_preferences()
assert settings.codec.currentData() == "mp4v"
assert settings.recording_options().codec == "mp4v"
window.close()
+64 -136
View File
@@ -1,7 +1,9 @@
"""The finger debugger's panel: picking, refusing, and what a row contains.
"""The geometry-and-mounting screen: the single-point trial.
Interaction tests use a small dome. Geometry and native-resolution checks keep
the real export and skip individually when that optional export is unavailable.
The point queue and its operation-sheet export live on the capture page and are
covered by ``test_capture_page.py``.
"""
from __future__ import annotations
@@ -398,9 +400,9 @@ def test_the_play_button_needs_a_picked_point(panel):
assert panel.sim_button.isEnabled()
def test_clearing_the_points_disables_the_simulation_again(panel):
def test_clearing_the_point_disables_the_simulation_again(panel):
panel.handle_pick(*_first_surface_pixel(panel.view.flat))
panel.clear_points()
panel.clear_point()
assert not panel.preview.can_simulate
assert not panel.sim_button.isEnabled()
@@ -539,155 +541,81 @@ def test_the_simulation_cannot_start_without_a_pose(bundle, preview):
assert preview._sim_transform() is None # noqa: SLF001
# ------------------------------------------------------------- the point table
# ------------------------------------------------------------- the single pick
def _pick_three(panel) -> list[dict]:
"""Fill the table with three real picks, spread over the surface."""
flat = panel.view.flat
picked: list[dict] = []
step = max(1, flat.width // 7)
for y in range(step, flat.height, step):
for x in range(step, flat.width, step):
if flat.sample(x, y) is None:
def _another_surface_pixel(flat, first) -> tuple[int, int]:
"""A second surface pixel, so a replacement pick is visibly different."""
for y in range(0, flat.normals.shape[0], 5):
for x in range(0, flat.normals.shape[1], 5):
if (x, y) == first or flat.sample(x, y) is None:
continue
row = panel.handle_pick(x, y)
if row is not None:
picked.append(row)
if len(picked) == 3:
break
if len(picked) == 3:
break
assert len(picked) == 3, f"only picked {len(picked)} points"
return picked
return x, y
raise AssertionError("the flat raster has no second surface pixel")
def test_the_table_has_a_select_column_and_an_editable_body(panel):
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QCheckBox
def test_the_window_names_the_screen_geometry_and_mounting(make_window):
"""P5: the debug screen is now the geometry and mounting page, in both places."""
from orisys_app_shared.i18n import t
_pick_three(panel)
assert panel.table.columnCount() == len(panel.COLUMNS) + 1
for row in range(panel.table.rowCount()):
holder = panel.table.cellWidget(row, panel.SELECT_COLUMN)
assert holder is not None and holder.findChild(QCheckBox) is not None
# The six input columns are editable; the solved ones are not.
for offset in range(len(panel.COLUMNS)):
item = panel.table.item(0, offset + 1)
editable = bool(item.flags() & Qt.ItemFlag.ItemIsEditable)
assert editable == (offset < panel.INPUT_COLUMNS), panel.COLUMNS[offset]
window = make_window()
try:
title = t("finger.title")
assert title == t("workspace.tab_finger")
assert title == "几何与安装", title # the default locale is zh_CN
assert window.finger_panel.title() == title
index = window.workspace.page_keys().index("finger")
assert window.workspace.tabText(index) == title
finally:
window.close()
def test_editing_a_coordinate_resolves_the_row_again(panel):
"""Typing a new sensor coordinate must re-solve, not leave a stale pose."""
_pick_three(panel)
before = panel.points()[0]
def test_the_screen_no_longer_keeps_a_point_table(panel):
"""P5: the queue owns points; this screen is the single-point trial.
item = panel.table.item(0, 1) # the x column
item.setText(f"{before['x'] + 1.0:.3f}")
after = panel.points()[0]
assert after["x"] == pytest.approx(before["x"] + 1.0, abs=1e-3)
# The solved columns moved with it, which is the point of re-resolving.
assert after["X"] != pytest.approx(before["X"], abs=1e-9)
assert after["residual"] < 1.0
The list, its editor and its export moved to the capture page's queue, so the
old widgets must not exist here any more.
"""
assert not hasattr(panel, "table")
assert not hasattr(panel, "export_sites")
assert not hasattr(panel, "clear_points")
assert not hasattr(panel, "acquisition_points")
assert not hasattr(panel, "export_sites_button")
def test_a_non_numeric_edit_is_refused_and_the_old_value_kept(panel):
_pick_three(panel)
before = panel.points()[0]["x"]
panel.table.item(0, 1).setText("not a number")
assert panel.points()[0]["x"] == pytest.approx(before)
assert panel.status_label.text()
def test_ticking_a_row_selects_it_and_clears_again(panel):
from PySide6.QtWidgets import QCheckBox
_pick_three(panel)
first = panel.table.cellWidget(0, panel.SELECT_COLUMN).findChild(QCheckBox)
third = panel.table.cellWidget(2, panel.SELECT_COLUMN).findChild(QCheckBox)
first.setChecked(True)
third.setChecked(True)
assert panel.selected_rows() == [0, 2]
first.setChecked(False)
assert panel.selected_rows() == [2]
def test_removing_selected_drops_exactly_those_rows(panel):
from PySide6.QtWidgets import QCheckBox
_pick_three(panel)
panel.table.cellWidget(1, panel.SELECT_COLUMN).findChild(QCheckBox).setChecked(True)
removed = panel.remove_selected()
assert removed == 1
assert panel.point_count() == 2
assert not panel.selected_rows()
def test_removing_with_nothing_ticked_drops_the_current_row(panel):
"""The obvious fallback: no tick means 'the row I am looking at'."""
_pick_three(panel)
panel.table.setCurrentCell(1, 1)
assert panel.remove_selected() == 1
assert panel.point_count() == 2
def test_a_row_can_be_moved_up_and_down(panel):
_pick_three(panel)
before = [row["x"] for row in panel.points()]
panel.table.setCurrentCell(0, 1)
assert panel.move_selected(1)
assert [row["x"] for row in panel.points()] == [before[1], before[0], before[2]]
assert panel.move_selected(-1)
assert [row["x"] for row in panel.points()] == before
def test_a_row_cannot_be_moved_off_the_end(panel):
_pick_three(panel)
before = [row["x"] for row in panel.points()]
panel.table.setCurrentCell(0, 1)
assert not panel.move_selected(-1)
assert [row["x"] for row in panel.points()] == before
def test_selection_marks_the_point_in_both_views(panel):
"""The red dot: the whole reason for selecting a row."""
_pick_three(panel)
panel.table.setCurrentCell(2, 1)
record = panel.points()[2]
assert panel.view.selected_pixel is not None
# And it is the right pixel: the one showing this row's 3D point.
pixel = panel.view.selected_pixel
point, _normal, _face = panel.view.flat.sample(*pixel)
assert point == pytest.approx([record["x"], record["y"], record["z"]], abs=0.05)
assert panel.preview._highlight_mm is not None # noqa: SLF001
def test_a_pick_marks_its_own_row_as_selected(panel):
def test_a_pick_keeps_exactly_one_point(panel):
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
first = _first_surface_pixel(flat)
second = _another_surface_pixel(flat, first)
first_row = panel.handle_pick(*first)
assert first_row is not None
assert panel.point_count() == 1
assert panel.points()[0]["u_px"] == first_row["u_px"]
second_row = panel.handle_pick(*second)
assert second_row is not None
# A second click replaces the first point rather than adding a row.
assert panel.point_count() == 1
stored = panel.points()[0]
assert (stored["u_px"], stored["v_px"]) == (second_row["u_px"], second_row["v_px"])
assert stored["u_px"] != first_row["u_px"]
def test_a_pick_marks_the_point_in_both_views(panel):
x, y = _first_surface_pixel(panel.view.flat)
panel.handle_pick(x, y)
assert panel.view.selected_pixel is not None
assert panel.preview._highlight_mm is not None # noqa: SLF001
def test_clearing_the_points_removes_the_markers(panel):
_pick_three(panel)
panel.clear_points()
def test_clearing_the_point_removes_the_markers(panel):
panel.handle_pick(*_first_surface_pixel(panel.view.flat))
panel.clear_point()
assert panel.point_count() == 0
assert panel.points() == []
assert panel.view.selected_pixel is None
assert panel.preview._highlight_mm is None # noqa: SLF001
@@ -1042,13 +970,13 @@ def test_picking_with_no_bundle_says_so(qt_app):
widget.deleteLater()
def test_clearing_points_empties_the_table(panel):
def test_clearing_the_point_empties_the_screen(panel):
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
panel.handle_pick(x, y)
assert panel.point_count() == 1
panel.clear_points()
panel.clear_point()
assert panel.point_count() == 0
assert panel.points() == []
@@ -1078,10 +1006,10 @@ def test_the_sensor_z_offset_reaches_the_solve(panel):
assert 0.0 < thick.contact_z_mm - thin.contact_z_mm < 4.0
assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm)
# Picking through the panel uses the same fields, so the two must agree.
panel.clear_points()
panel.clear_point()
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
thin_row = panel.handle_pick(x, y)
panel.clear_points()
panel.clear_point()
panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0)
thick_row = panel.handle_pick(x, y)
assert thin_row is not None and thick_row is not None
+153 -104
View File
@@ -243,103 +243,112 @@ def test_input_end_aborts_active_hold(trial_setup, clock):
trial.finish_session()
def test_operator_export_reproduces_uv_sites_and_rejects_edited_xyz(qt_app, flat, tmp_path):
from orisys_autocal.sensor.export_bundle import SensorBundle
from orisys_autocal.ui.sensor_panel import FingerPanel
def test_a_trial_recorded_without_video_finishes_complete(trial_setup, clock):
"""Data-only recording: the force rows are the evidence and the verdict says so."""
from orisys_autocal.recording_options import RecordingOptions
panel = FingerPanel()
bundle = SensorBundle(
path=tmp_path, vertices_mm=np.array([[2, 1, 3], [4, 1, 3], [2, 4, 3]]),
faces=np.array([[0, 1, 2]]), uv_data=np.array([[0, 0], [1, 0], [0, 1]]),
loop_to_vertex=np.array([0, 1, 2]),
)
panel.set_bundle(bundle, flat)
panel.handle_pick(2, 4)
panel.handle_pick(4, 1)
panel.table.setCurrentCell(1, 1)
panel.move_selected(-1)
destination = panel.export_sites(tmp_path / "sites.csv")
from orisys_autocal.record_overview import read_plan, plan_points
from orisys_autocal.sensor.uv_flat import mapping_sha256
definition = {"origin_raster_xy_px": uv_origin(flat), "raster_size_hw": [flat.height, flat.width],
"mapping_sha256": mapping_sha256(flat), "u_positive": "right", "v_positive": "up"}
imported = plan_points(read_plan(destination, definition), definition)
assert [point["uv_xy_px"] for point in imported] == [[1, 2], [-1, -1]]
with destination.open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
assert [(int(r["u_px"]), int(r["v_px"])) for r in rows] == [(1, 2), (-1, -1)]
for row in rows:
site = site_at(flat, int(row["u_px"]), int(row["v_px"]))
assert site.position_sensor_mm == tuple(float(row[f"{axis}_sensor_mm"]) for axis in "xyz")
assert row["mapping_sha256"] == site.mapping_sha256
panel.table.item(0, 1).setText("999")
with pytest.raises(ValueError):
panel.export_sites(tmp_path / "invalid.csv")
assert not (tmp_path / "invalid.csv").exists()
panel.deleteLater()
def test_gui_hold_controls_and_session_lifecycle(make_window, trial_setup, clock, flat, tmp_path, qt_app):
from orisys_app_shared.i18n import t
window = make_window()
panel = window.calibration_panel
worker = Worker()
panel.source_panel.worker = worker
panel.trial.worker = worker
panel.source_panel.mode = SimpleNamespace(fps=30)
panel.source_panel.config_ref.setText(trial_setup["config_ref"])
panel.set_flat(flat, trial_setup["bundle_path"])
panel._picked(2, 1)
panel.output_path.setText(str(trial_setup["root"]))
panel.sensor_sn.setText("F-001")
assert panel.sensor_model.currentText() == "finger"
panel.levels.setText("5, 10")
panel.start_button.click()
assert panel.trial.status == "recording"
manifest = json.loads((panel.trial.session.directory / "session.json").read_text(encoding="utf-8"))
assert manifest["uv_outline"]["surface"]
assert manifest["uv_outline"]["mapping_sha256"] == manifest["uv_definition"]["mapping_sha256"]
trial = ManualTrial(worker)
directory = trial.start(**{**trial_setup, "recording": RecordingOptions(video=False)})
for index, (_branch, target) in enumerate(trial.steps):
trial.confirm(target)
# Two frames inside the second half of the hold: the summary's own rule.
# Each frame carries its own id: a repeated id has no single acquisition
# time and the review would exclude it.
for delay, frame_id in ((15, index * 2 + 2), (1, index * 2 + 3)):
clock.advance(delay)
worker.packet = replace(
worker.packet, capture_mono_ns=clock.now,
frame_id=frame_id, source_frame_id=frame_id,
)
worker.emit()
clock.advance(4)
trial.poll()
assert trial.step_index == index + 1
trial.finish()
assert trial.recorder.stop(wait=True).ok
assert trial.poll() == "complete"
assert not list(directory.glob("raw_*.mp4"))
root = json.loads(
(directory.parent.parent / "session.json").read_text(encoding="utf-8")
)
assert root["recording"] == {
"video": False, "codec": "mp4v", "lossless_frames": False}
from orisys_autocal.summary import build_summary
summary = build_summary(directory.parent.parent)
assert all(row["eligible"] for row in summary["windows"]), [
(row["excluded_reason"], row["capture_status"], row["samples_valid"])
for row in summary["windows"]
]
assert all(row["capture_status"] == "complete" for row in summary["windows"])
trial.finish_session()
def test_recording_settings_are_locked_by_the_first_trial(trial_setup, clock):
"""One session, one recording choice: the manifest must not change mid-session."""
from orisys_autocal.recording_options import RecordingOptions
worker = Worker()
trial = ManualTrial(worker)
directory = trial.start(
**{**trial_setup, "recording": RecordingOptions(video=False)}
)
root = json.loads(
(directory.parent.parent / "session.json").read_text(encoding="utf-8")
)
assert root["recording"] == {
"video": False, "codec": "mp4v", "lossless_frames": False}
worker.emit()
assert panel.views.currentWidget() is panel.source_panel
assert not panel.sensor_sn.isEnabled()
assert not panel.close_session_button.isEnabled()
for index, force in enumerate((0, 5, 10, 5, 0)):
panel.reference.setText(str(force))
panel.confirm_button.click()
assert panel.trial.holding
assert not panel.confirm_button.isEnabled()
assert not panel.finish_button.isEnabled()
assert panel.trial.step_index == index
assert t("calibration.holding").split("{")[0] in panel.target_label.text()
if index == 0:
panel.resize(1120, 740)
panel.show()
qt_app.processEvents()
assert panel.grab().save(str(tmp_path / "operator-hold.png"))
clock.advance(20)
panel._poll()
assert panel.trial.step_index == index + 1
assert panel.finish_button.isEnabled()
panel.finish_button.click()
panel.trial.recorder.stop(wait=True)
panel._poll()
assert panel.close_session_button.isEnabled()
assert not panel.sensor_sn.isEnabled()
panel.close_session_button.click()
assert panel.trial.session is None
assert panel.sensor_sn.isEnabled()
trial.finish(aborted=True, reason="test ended")
trial.recorder.stop(wait=True)
trial.poll()
with pytest.raises(ValueError, match="locked"):
trial.start(**{**trial_setup, "recording": RecordingOptions(video=True)})
# The locked settings are what a later trial of this session uses.
second = trial.start(
**{**trial_setup, "recording": RecordingOptions(video=False)}
)
assert trial.recording.video is False
assert second.parent.parent == directory.parent.parent
trial.recorder.stop(wait=True)
trial.poll()
trial.finish_session()
def test_the_capture_page_no_longer_drives_a_manual_trial(make_window):
"""D1: a new capture is a one-point queue in the ``sites/`` format.
The ManualTrial flow stays only so old ``trials/`` sessions keep reading; the
capture page owns a Campaign plus the workflow controller. The two-page
single/multi-point split it used to switch between is gone.
"""
window = make_window()
page = window.calibration_panel
assert not hasattr(page, "trial")
assert not hasattr(page, "multipoint")
assert page.campaign is not None
assert page.controller is not None
window.close()
def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view(
make_window,
def test_the_capture_page_keeps_the_picked_uv_point_while_the_live_view_is_visible(
make_window, qt_app
):
from orisys_app_shared.i18n import t
"""Both images are on the page at once; picking on the map keeps the point."""
from orisys_autocal.sensor.uv_flat import FlatSurface
window = make_window()
window.resize(1440, 900)
window.show()
qt_app.processEvents()
window.workspace.set_current_key("sensor")
qt_app.processEvents()
mask = np.full((5, 7), -1, dtype=np.int32)
mask[1:4, 2:5] = 2
positions = np.zeros((5, 7, 3), dtype=np.float64)
@@ -353,21 +362,61 @@ def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view(
uv_min=(0, 0),
uv_max=(1, 1),
)
panel = window.calibration_panel
for key, button in (
("start", panel.start_button),
("confirm", panel.confirm_button),
("finish", panel.finish_button),
("abort", panel.abort_button),
):
assert button.text() == t(f"calibration.{key}")
assert button.toolTip() == t(f"calibration.{key}_tip")
assert panel.target_label.text() == t("calibration.before_start")
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)
page = window.calibration_panel
page.set_flat(flat, "bundle")
page._picked(2, 1)
assert (page.u_spin.value(), page.v_spin.value()) == (-1, 2)
assert page._site.raster_xy_px == (2, 1)
assert "2.000" in page.position_value.text()
# The UV map and the live 2-D image are visible in the same row: selecting one
# used to hide the other behind a tab.
assert page.flat_view.isVisible() and page.live_view.isVisible()
window.close()
def test_the_session_row_opens_the_folder_the_summary_and_the_path(
make_window, tmp_path, monkeypatch
):
"""The operator can reach the recording without copying a path by hand."""
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
from evidence_helpers import multipoint_session
from orisys_autocal.summary import build_summary
from orisys_autocal.summary_export import publish_summary
from orisys_autocal.ui import calibration_panel as panel_module
window = make_window()
panel = window.calibration_panel
opened: list = []
monkeypatch.setattr(
panel_module, "reveal", lambda path, select=None: opened.append((path, select)) or True
)
# Nothing has been recorded yet: each action is disabled and says why.
panel._refresh()
assert not panel.open_session_button.isEnabled()
assert not panel.open_summary_button.isEnabled()
assert not panel.copy_path_button.isEnabled()
assert panel.open_session_button.toolTip() == t("cap.block.no_directory")
assert panel.open_summary_button.toolTip() == t("cap.block.no_summary")
root = multipoint_session(tmp_path / "session", sites=("P1",), repeats=1, levels=(5.0,))
publish_summary(root, build_summary(root))
# The page finds the session the campaign is recording into.
panel.campaign.directory = root
panel._refresh()
assert panel._session_directory() == root
assert panel.open_session_button.isEnabled()
assert panel.open_summary_button.isEnabled()
assert panel.copy_path_button.isEnabled()
assert panel.open_session_button.toolTip() == t("cap.action.open_session")
panel.open_session_button.click()
assert opened[-1][0] == root
panel.open_summary_button.click()
assert opened[-1][0] == root / "summary"
panel.copy_path_button.click()
assert QApplication.clipboard().text() == str(root)
window.close()
+108 -83
View File
@@ -1,13 +1,16 @@
"""Aligning the normal solves the point again instead of trusting the stored pose.
"""Aligning a point re-solves it against the geometry in force now.
The stored pose is a record of what the solver said when the point was taken.
Mounting offsets, the mounting angle, the tool position and the travel limits are
all editable afterwards, so a stale pose can name a target this machine no longer
reaches. These tests drive the panel's own align path and watch what it emits.
reaches. These tests drive the capture page's own align path and watch what it
emits; the window that would plan the move is not involved.
"""
from __future__ import annotations
import json
import numpy as np
import pytest
@@ -41,13 +44,14 @@ def _prefix(key: str) -> str:
return t(key).split("{")[0]
def _add_site(panel, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None):
def _add_site(page, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None):
solved = stack.solve(
np.asarray(point, dtype=np.float64),
np.asarray(normal, dtype=np.float64),
)
assert solved.ok
panel.campaign.add_solved_site(
page.controller.set_max_force(20.0)
page.controller.add_point(
site_id,
float(point[0]),
float(point[1]),
@@ -68,107 +72,110 @@ def _add_site(panel, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None
[5.0],
1.0,
1,
max_force_n=20.0,
uv_px=uv,
)
return solved
def _watch(panel, window) -> list[dict]:
"""Record what the panel emits, without letting the window plan a move."""
panel.alignRequested.disconnect()
def _watch(page) -> list[dict]:
"""Record what the page emits, without letting the window plan a move."""
page.alignRequested.disconnect()
emitted: list[dict] = []
panel.alignRequested.connect(emitted.append)
page.alignRequested.connect(emitted.append)
return emitted
def test_aligning_solves_the_point_against_the_mounting_in_force_now(make_window):
window = make_window()
panel = window.calibration_panel.multipoint
stored = _add_site(panel, window.finger_stack)
panel._selected = "P1" # the panel is the unit under test - the table's selection, set without a click
emitted = _watch(panel, window)
page = window.calibration_panel
page._flat = _flat() # the page re-solves a UV point against the current map
stored = _add_site(page, window.finger_stack, uv=(0.0, 0.0))
emitted = _watch(page)
panel._align() # the panel is the unit under test
assert emitted, panel.message.text()
page._align() # the page is the unit under test
assert emitted, page.message.text()
assert emitted[-1]["z"] == pytest.approx(stored.contact_z_mm)
# The window reports the move cancelled, so the point stays unaligned and the
# page keeps offering an align for a retry.
page.alignment_failed("cancelled")
# The operator raises the sensor on its plate: the same point now needs a
# different pose, and the panel has to ask the solver again.
# different pose, and the page has to ask the solver again.
window.finger_panel.sensor_z_spin.setValue(12.0)
panel._align() # the panel is the unit under test
assert len(emitted) == 2, panel.message.text()
fresh = panel.campaign.sites[0].pose
page._align() # the page is the unit under test
assert len(emitted) == 2, page.message.text()
fresh = page.controller.campaign.sites[0].pose
assert fresh["z"] == pytest.approx(emitted[-1]["z"])
assert fresh["z"] != pytest.approx(stored.contact_z_mm)
window.close()
def test_a_point_the_map_has_moved_asks_to_be_taken_again(make_window):
window = make_window()
panel = window.calibration_panel.multipoint
window.calibration_panel._flat = _flat() # the panel is the unit under test - the loaded surface
_add_site(panel, window.finger_stack, uv=(0.0, 0.0))
page = window.calibration_panel
page._flat = _flat()
_add_site(page, window.finger_stack, uv=(0.0, 0.0))
# The record says the point is half a millimetre from where the map puts it.
panel.campaign.sites[0].x += 0.5
panel._selected = "P1" # the panel is the unit under test
emitted = _watch(panel, window)
page.controller.campaign.sites[0].x += 0.5
emitted = _watch(page)
panel._align() # the panel is the unit under test
page._align()
assert not emitted
assert _prefix("multipoint.point_moved") in panel.message.text()
assert _prefix("multipoint.point_moved") in page.message.text()
window.close()
def test_a_point_whose_normal_has_moved_asks_to_be_taken_again(make_window):
window = make_window()
panel = window.calibration_panel.multipoint
window.calibration_panel._flat = _flat() # the panel is the unit under test
_add_site(panel, window.finger_stack, uv=(0.0, 0.0))
page = window.calibration_panel
page._flat = _flat()
_add_site(page, window.finger_stack, uv=(0.0, 0.0))
tilted = np.asarray(NORMAL, dtype=np.float64)
tilted = tilted / np.linalg.norm(tilted)
panel.campaign.sites[0].normal = (float(tilted[0]), float(tilted[1]), float(-tilted[2]))
panel._selected = "P1" # the panel is the unit under test
emitted = _watch(panel, window)
page.controller.campaign.sites[0].normal = (float(tilted[0]), float(tilted[1]), float(-tilted[2]))
emitted = _watch(page)
panel._align() # the panel is the unit under test
page._align()
assert not emitted
assert _prefix("multipoint.normal_moved") in panel.message.text()
assert _prefix("multipoint.normal_moved") in page.message.text()
window.close()
def test_a_point_without_a_usable_source_is_refused(make_window):
window = make_window()
panel = window.calibration_panel.multipoint
_add_site(panel, window.finger_stack)
panel.campaign.sites[0].position_frame = "fixture"
panel._selected = "P1" # the panel is the unit under test
emitted = _watch(panel, window)
page = window.calibration_panel
_add_site(page, window.finger_stack)
page.controller.campaign.sites[0].position_frame = "fixture"
emitted = _watch(page)
panel._align() # the panel is the unit under test
page._align()
assert not emitted
assert _prefix("multipoint.point_unmapped") in panel.message.text()
assert _prefix("multipoint.point_unmapped") in page.message.text()
window.close()
def test_a_pose_the_travel_no_longer_reaches_is_never_commanded(make_window):
window = make_window()
panel = window.calibration_panel.multipoint
_add_site(panel, window.finger_stack)
panel._selected = "P1" # the panel is the unit under test
emitted = _watch(panel, window)
page = window.calibration_panel
page._flat = _flat()
_add_site(page, window.finger_stack, uv=(0.0, 0.0))
emitted = _watch(page)
# A yaw axis with no travel cannot reach any of the measured points, and the
# stored pose still says it could.
window.finger_panel.stack.yaw_limit_deg = 0.0
panel._align() # the panel is the unit under test
page._align()
assert not emitted
assert _prefix("multipoint.pose_refused") in panel.message.text()
assert _prefix("multipoint.pose_refused") in page.message.text()
window.close()
def test_a_point_cannot_be_aligned_in_the_middle_of_a_run(make_window, tmp_path):
window = make_window()
panel = window.calibration_panel.multipoint
_add_site(panel, window.finger_stack)
panel._selected = "P1" # the panel is the unit under test
emitted = _watch(panel, window)
panel.campaign.start(
page = window.calibration_panel
_add_site(page, window.finger_stack)
emitted = _watch(page)
page.campaign.start(
tmp_path,
sensor_model="finger",
sensor_sn="SN1",
@@ -176,42 +183,60 @@ def test_a_point_cannot_be_aligned_in_the_middle_of_a_run(make_window, tmp_path)
max_force_n=20.0,
simulate=True,
)
page._refresh()
panel._align() # the panel is the unit under test
page._align()
assert not emitted
assert t("multipoint.align_busy") in panel.message.text()
assert t("multipoint.align_busy") in page.message.text()
window.close()
def test_next_site_can_be_aligned_between_trials(make_window, tmp_path):
import json
def test_next_site_is_aligned_from_its_recorded_pose_between_trials(make_window, tmp_path):
"""Between trials the queue is frozen, so the recorded pose is what moves.
Re-solving and storing is an idle-only queue edit (interfaces 1.10/1.11), so the
page does not pretend to retarget the next point mid-session: the point's stored
pose is used unchanged, and re-solving after a mounting change inside a running
session is P5's job. The guard that the point itself did not move still runs.
"""
window = make_window()
panel = window.calibration_panel.multipoint
_add_site(panel, window.finger_stack, site_id="P1")
_add_site(panel, window.finger_stack, site_id="P2")
emitted = _watch(panel, window)
panel.campaign.start(tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20.0, simulate=True)
while panel.campaign.phase == "load":
target = panel.campaign.progress()["target_n"]
panel.campaign.confirm_reference(target)
panel.campaign.complete_hold(
now_ns=panel.campaign._hold_started_ns + int(panel.campaign._hold_s * 1e9))
assert panel.campaign.phase == "liftoff"
panel._selected = "P2"
panel._refresh()
assert panel.align_button.isEnabled()
previous = json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text())
page = window.calibration_panel
page._flat = _flat()
_add_site(page, window.finger_stack, site_id="P1", uv=(0.0, 0.0))
_add_site(page, window.finger_stack, site_id="P2", uv=(1.0, 1.0))
emitted = _watch(page)
campaign = page.controller.campaign
campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20.0, simulate=True,
)
while campaign.phase == "load":
target = campaign.progress()["target_n"]
campaign.confirm_reference(target)
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9))
assert campaign.phase == "liftoff"
page._refresh()
assert page.controller.primary_action().key == "align_point"
previous = json.loads(
(campaign.directory / "sites/P1/repeat-001/session.json").read_text()
)
recorded = page.controller.model().sites[1].pose["z"]
window.finger_panel.sensor_z_spin.setValue(12.0)
panel._align()
page._align()
assert len(emitted) == 1
manifest = json.loads((panel.campaign.directory / "session.json").read_text())
assert manifest["sites"][1]["pose"]["z"] == emitted[0]["z"]
panel.campaign.confirm_liftoff()
repeat = json.loads((panel.campaign.directory / "sites/P2/repeat-001/session.json").read_text())
assert emitted[0]["z"] == pytest.approx(recorded)
# The move is reported finished by the window; until then no start is offered.
page.alignment_succeeded()
assert page.controller.primary_action().key == "confirm_liftoff"
page.primary_button.click() # confirm liftoff -> the next point's first repeat
assert campaign.phase == "load"
manifest = json.loads((campaign.directory / "session.json").read_text())
assert manifest["sites"][1]["pose"]["z"] == pytest.approx(recorded)
repeat = json.loads((campaign.directory / "sites/P2/repeat-001/session.json").read_text())
assert repeat["pose"] == manifest["sites"][1]["pose"]
assert json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text()) == previous
panel._selected = "P1"
panel._align()
assert json.loads((campaign.directory / "sites/P1/repeat-001/session.json").read_text()) == previous
page._align()
assert len(emitted) == 1
panel.campaign.stop("test finished")
campaign.stop("test finished")
window.close()
+116 -47
View File
@@ -15,6 +15,7 @@ import pytest
from evidence_helpers import FakeWorker
from orisys_autocal.multipoint import Campaign
from orisys_autocal.recording_options import RecordingOptions
class _Clock:
@@ -46,10 +47,18 @@ def _campaign(*, repeats: int = 2) -> Campaign:
return campaign
def _start(campaign: Campaign, root, *, simulate: bool = False) -> "object":
def _start(
campaign: Campaign,
root,
*,
simulate: bool = False,
recording: RecordingOptions | None = None,
codec_availability: dict | None = None,
) -> "object":
return campaign.start(
root, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="fixture", max_force_n=20, simulate=simulate,
recording=recording, codec_availability=codec_availability,
)
@@ -323,74 +332,134 @@ def test_a_repeat_directory_that_already_exists_is_never_reused(tmp_path):
campaign._advance_after_repeat()
def test_the_confirm_button_waits_until_the_capture_is_recording(make_window, tmp_path):
"""A hold confirmed before the writer exists would be evidence with no video."""
def test_a_live_start_attaches_the_recorder_before_offering_a_confirmation(
make_window, tmp_path
):
"""A hold confirmed before the writer exists would be evidence with no video.
The page owns the attach, so by the time it reports a started session the
repeat is already recording and the controller offers the confirmation.
"""
from types import SimpleNamespace
from orisys_app_shared.i18n import t
window = make_window()
panel = window.calibration_panel
multipoint = panel.multipoint
worker = FakeWorker()
panel.source_panel.worker = worker
panel.source_panel.mode = SimpleNamespace(fps=10)
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
panel.output_path.setText(str(tmp_path))
panel.sensor_sn.setText("SN1")
from orisys_autocal.motion import BusStatus
# A started campaign whose capture was never attached: not ready.
multipoint.campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20, simulate=False,
window = make_window(move_confirm=False)
window.poller.service._spec = object() # noqa: SLF001 - connected, without opening anything
window.poller.service._bus = BusStatus( # noqa: SLF001
bus_type="ecat", init_status=1, total_axes=5
)
panel._sync()
assert multipoint.campaign.phase == "load"
assert not panel.confirm_button.isEnabled()
assert t("multipoint.capture_wait") in panel.target_label.text()
panel.reference.setText("0")
panel._confirm()
assert multipoint.campaign.phase == "load"
page = window.calibration_panel
window.workspace.set_current_key("sensor")
worker = FakeWorker()
page.source_panel.worker = worker
page.source_panel.mode = SimpleNamespace(fps=10)
page._flat = object() # a loaded bundle is all the readiness check needs here
page.settings.output_path.setText(str(tmp_path))
page.settings.sensor_sn.setText("SN1")
page.settings.max_force.setText("20")
page.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
# The window confirmed the move, so the session may start.
page.alignment_succeeded()
page._refresh()
multipoint.campaign.attach_capture(worker, fps=10.0)
panel._sync()
assert panel.confirm_button.isEnabled()
assert multipoint.campaign.capture_ready
action = page.controller.primary_action()
assert action.key == "start_session", (action, page.message.text())
page.primary_button.click()
campaign = page.campaign
assert campaign.phase == "load"
assert campaign.capture_state == "recording"
assert page.controller.primary_action().key == "confirm_target"
assert t("multipoint.capture_recording") in page.capture_label.text()
window.close()
def test_a_restarted_source_is_reported_on_the_panel(make_window, tmp_path):
def test_a_restarted_source_is_reported_on_the_capture_page(make_window, tmp_path):
from types import SimpleNamespace
from orisys_app_shared.i18n import t
window = make_window()
panel = window.calibration_panel
multipoint = panel.multipoint
worker = FakeWorker()
panel.source_panel.worker = worker
panel.source_panel.mode = SimpleNamespace(fps=10)
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
multipoint.campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20, simulate=False,
from orisys_autocal.motion import BusStatus
window = make_window(move_confirm=False)
window.poller.service._spec = object() # noqa: SLF001
window.poller.service._bus = BusStatus( # noqa: SLF001
bus_type="ecat", init_status=1, total_axes=5
)
multipoint.campaign.attach_capture(worker, fps=10.0)
multipoint.campaign.poll()
page = window.calibration_panel
window.workspace.set_current_key("sensor")
worker = FakeWorker()
page.source_panel.worker = worker
page.source_panel.mode = SimpleNamespace(fps=10)
page._flat = object()
page.settings.output_path.setText(str(tmp_path))
page.settings.sensor_sn.setText("SN1")
page.settings.max_force.setText("20")
page.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
page.alignment_succeeded()
page._refresh()
page.primary_button.click()
campaign = page.campaign
assert campaign.capture_state == "recording"
worker.restart()
worker.emit()
multipoint._poll()
_drain(multipoint.campaign)
multipoint._refresh()
page._poll()
_drain(campaign)
page._refresh()
assert multipoint.campaign.phase == "aborted"
assert t("multipoint.capture_incomplete") in multipoint.capture_status.text()
assert t("multipoint.capture_reason_restarted") in multipoint.capture_status.text()
assert campaign.phase == "aborted"
assert t("multipoint.capture_incomplete") in page.capture_label.text()
assert t("multipoint.capture_reason_restarted") in page.capture_label.text()
manifest = json.loads(
(multipoint.campaign.directory / "sites" / "P1" / "repeat-001" / "session.json")
(campaign.directory / "sites" / "P1" / "repeat-001" / "session.json")
.read_text(encoding="utf-8")
)
assert manifest["capture_status"] == "incomplete"
assert manifest["capture_reason"] == "source_restarted"
window.close()
def test_a_repeat_recorded_without_video_finishes_complete(tmp_path, clock):
"""Choosing data only must not make every repeat incomplete."""
from orisys_autocal.session_review import review_session
from orisys_autocal.summary import build_summary
campaign = _campaign(repeats=1)
root = _start(campaign, tmp_path, recording=RecordingOptions(video=False))
worker = FakeWorker(clock=lambda: clock.now)
campaign.attach_capture(worker, fps=10.0)
_record_repeat(campaign, worker, clock=clock)
assert campaign.phase == "done"
session = _session(root)
assert session["recording"] == {
"video": False, "codec": "mp4v", "lossless_frames": False}
trial = session["trials"][0]
assert trial["status"] == "complete"
assert trial["capture_status"] == "complete"
capture = root / trial["directory"] / "capture"
assert not list(capture.glob("raw_*.mp4"))
review = review_session(root)
assert review["recording"]["video"] is False
summary = build_summary(root)
assert all(row["eligible"] for row in summary["windows"])
assert all(row["FN_mean"] is not None for row in summary["windows"])
def test_an_unavailable_codec_is_refused_before_the_session_starts(tmp_path):
"""No silent fallback to mp4v: an encoder that failed the probe stops the start."""
campaign = _campaign(repeats=1)
availability = {"mp4v": True, "avc1": False, "H264": False}
with pytest.raises(ValueError, match="avc1"):
_start(campaign, tmp_path, recording=RecordingOptions(codec="avc1"),
codec_availability=availability)
assert campaign.directory is None
assert campaign.phase == "idle"
assert list(tmp_path.iterdir()) == []
@@ -75,3 +75,28 @@ def test_a_window_that_closes_still_lands_in_the_scratch_directory(qt_app):
after = INSTALLED.read_bytes() if INSTALLED.is_file() else None
assert after == before, "closing a test window rewrote the installed preferences"
def test_a_stored_recording_block_without_the_newer_keys_gets_each_default():
"""The upgrade path for the recording preference, through the same net.
A stored ``recording`` block replaces the whole default block, exactly like
``safety_limits``: a codec or the lossless flag added later would otherwise
read as absent and the recorder would be configured from a hole.
"""
import json
from orisys_app_shared.paths import preference_path
from orisys_autocal.preferences import load_preferences
from orisys_autocal.recording_options import RecordingOptions, default_options
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"recording": {"video": False}}), encoding="utf-8")
options = default_options(load_preferences())
assert options == RecordingOptions(video=False)
assert options.codec == "mp4v"
assert options.lossless_frames is False
@@ -0,0 +1,100 @@
"""Recording options: the settings a session is recorded with, and what can encode.
The defaults are a decision, not a preference: the measured mp4v bitrate is the
baseline until H.264 equivalence is shown (plan D2), so a missing block must mean
"video expected, mp4v" rather than whatever the machine happens to encode.
"""
from __future__ import annotations
import pytest
from orisys_autocal.recording_options import (
CODEC_CANDIDATES,
DEFAULT_CODEC,
DEFAULT_LOSSLESS_FRAMES,
DEFAULT_VIDEO,
RecordingOptions,
default_options,
make_record_config,
probe_codecs,
require_codec_available,
)
def test_the_defaults_are_the_confirmed_recording_decision():
options = RecordingOptions()
assert options.video is DEFAULT_VIDEO is True
assert options.codec == DEFAULT_CODEC == "mp4v"
assert options.lossless_frames is DEFAULT_LOSSLESS_FRAMES is False
assert options.as_block() == {
"video": True, "codec": "mp4v", "lossless_frames": False}
def test_the_block_round_trips_through_a_session_manifest():
options = RecordingOptions(video=False, codec="avc1", lossless_frames=True)
assert RecordingOptions.from_block(options.as_block()) == options
def test_a_missing_or_partial_block_means_video_was_expected():
"""Old sessions have no block; their video is required, so they keep behaving."""
assert RecordingOptions.from_block(None) == RecordingOptions()
assert RecordingOptions.from_block({}) == RecordingOptions()
assert RecordingOptions.from_block("not a block") == RecordingOptions()
# One stored key is not a reason to discard the others.
assert RecordingOptions.from_block({"video": False}) == RecordingOptions(video=False)
assert RecordingOptions.from_block({"codec": "avc1"}) == RecordingOptions(codec="avc1")
def test_make_record_config_maps_every_option_and_keeps_the_trial_window():
options = RecordingOptions(video=False, codec="avc1", lossless_frames=True)
config = make_record_config(options, 12.5)
assert config.video is False
assert config.codec == "avc1"
assert config.lossless_frames is True
assert config.fps == 12.5
# One continuous recording per trial: the recorder is never segmented.
assert config.segment_seconds == 24 * 3600.0
assert config.preserve_all_frames is True
def test_the_default_options_read_the_preference_block_with_fallbacks():
assert default_options({}) == RecordingOptions()
assert default_options(None) == RecordingOptions()
assert default_options({"recording": {"video": False, "codec": "avc1",
"lossless_frames": True}}) == RecordingOptions(
video=False, codec="avc1", lossless_frames=True)
# The upgrade path: a block saved before a key existed still gets its default.
assert default_options({"recording": {"video": False}}) == RecordingOptions(video=False)
def test_probing_reports_every_candidate_and_leaves_no_probe_files(tmp_path):
scratch = tmp_path / "scratch"
availability = probe_codecs(scratch)
assert set(availability) == set(CODEC_CANDIDATES)
# The suite writes real mp4v clips everywhere else, so this one must be found.
assert availability["mp4v"] is True
assert all(isinstance(value, bool) for value in availability.values())
assert list(scratch.rglob("*.mp4")) == []
def test_an_unavailable_codec_is_refused_before_recording():
availability = {"mp4v": True, "avc1": False, "H264": False}
with pytest.raises(ValueError, match="avc1"):
require_codec_available(RecordingOptions(codec="avc1"), availability)
# The probes that passed are accepted, and a data-only recording needs no codec.
require_codec_available(RecordingOptions(codec="mp4v"), availability)
require_codec_available(RecordingOptions(video=False, codec="avc1"), availability)
def test_an_unprobed_codec_is_left_to_the_caller():
"""Availability is probed once at start-up; without a result nothing is assumed."""
require_codec_available(RecordingOptions(codec="avc1"), None)
+94
View File
@@ -0,0 +1,94 @@
"""Opening a session folder in the platform file manager.
The operator needs the folder the recording is in, not a dialog that only shows a
path string. The launch is mocked here, because the test must assert the exact
argument the platform file manager receives — ``explorer /select,<file>`` is a
single argument whose comma and quoting are the part that breaks silently.
"""
from __future__ import annotations
import pytest
from orisys_autocal.ui import reveal as reveal_module
def _capture_launch(monkeypatch) -> list:
calls: list = []
monkeypatch.setattr(reveal_module.subprocess, "Popen", lambda command: calls.append(command))
return calls
@pytest.mark.parametrize(
("platform", "executable"),
[("win32", "explorer"), ("linux", "xdg-open"), ("darwin", "open")],
)
def test_revealing_a_folder_uses_the_platform_file_manager(
tmp_path, monkeypatch, platform, executable
):
folder = tmp_path / "session"
folder.mkdir()
monkeypatch.setattr(reveal_module.sys, "platform", platform)
calls = _capture_launch(monkeypatch)
assert reveal_module.reveal(folder) is True
assert calls == [[executable, str(folder)]]
def test_a_selected_file_is_highlighted_with_explorer(tmp_path, monkeypatch):
folder = tmp_path / "session"
folder.mkdir()
video = folder / "raw_0001.mp4"
video.write_bytes(b"video")
monkeypatch.setattr(reveal_module.sys, "platform", "win32")
calls = _capture_launch(monkeypatch)
assert reveal_module.reveal(folder, select=video) is True
# One command-line string: the comma belongs to the switch and the path is
# quoted, which is what explorer expects for a path with spaces.
assert calls == [f'explorer /select,"{video}"']
def test_a_selected_file_that_is_gone_falls_back_to_the_folder(tmp_path, monkeypatch):
folder = tmp_path / "session"
folder.mkdir()
missing = folder / "raw_0001.mp4"
monkeypatch.setattr(reveal_module.sys, "platform", "win32")
calls = _capture_launch(monkeypatch)
assert reveal_module.reveal(folder, select=missing) is True
assert calls == [["explorer", str(folder)]]
def test_a_missing_path_is_not_opened(tmp_path, monkeypatch):
monkeypatch.setattr(reveal_module.sys, "platform", "win32")
calls = _capture_launch(monkeypatch)
assert reveal_module.reveal(tmp_path / "gone") is False
assert calls == []
def test_no_path_is_not_opened(monkeypatch):
calls = _capture_launch(monkeypatch)
assert reveal_module.reveal(None) is False
assert reveal_module.reveal("") is False
assert calls == []
def test_a_failed_launch_is_reported_instead_of_raised(tmp_path, monkeypatch):
folder = tmp_path / "session"
folder.mkdir()
monkeypatch.setattr(reveal_module.sys, "platform", "win32")
def fail(command):
raise OSError("no file manager")
monkeypatch.setattr(reveal_module.subprocess, "Popen", fail)
assert reveal_module.reveal(folder) is False
+42
View File
@@ -263,3 +263,45 @@ def test_an_axis_without_samples_is_unavailable_without_blocking_the_others(tmp_
assert by_axis["FY"]["R2"] is None
assert by_axis["FY"]["slope_raw_per_N"] == pytest.approx(0.0)
assert summary["windows"][0]["axis_samples"]["FX"] == 0
#: The block a session recorded without video writes (interfaces.md 2.2).
VIDEO_OFF = {"video": False, "codec": "mp4v", "lossless_frames": False}
def test_a_session_recorded_without_video_is_still_eligible(tmp_path):
"""The operator chose data only: the force rows are the evidence, not a video."""
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
video=False, recording_block=VIDEO_OFF, capture_status="complete",
)
summary = build_summary(root)
assert summary["windows"]
assert all(row["capture_status"] == "complete" for row in summary["windows"])
assert all(row["eligible"] for row in summary["windows"])
assert all(row["FN_mean"] is not None for row in summary["windows"])
assert summary["sensitivity"]
assert summary["missing_reasons"] == []
def test_a_session_without_a_recording_block_still_expects_video(tmp_path):
"""Old sessions have no block, so the video stays required exactly as before."""
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
video=False, # data rows only, and no block saying that was intended
)
from orisys_autocal.recording_options import RecordingOptions
from orisys_autocal.session_review import review_session
review = review_session(root)
assert review["recording"] == RecordingOptions().as_block()
assert RecordingOptions.from_block(review.get("recording")).video is True
summary = build_summary(root)
assert all(row["eligible"] is False for row in summary["windows"])
assert all(row["excluded_reason"] == REASON_NO_FRAME_SOURCE
for row in summary["windows"])
assert REASON_NO_FRAME_SOURCE in summary["missing_reasons"]
+89
View File
@@ -3,7 +3,9 @@
from __future__ import annotations
import csv
import hashlib
import json
import logging
import pytest
@@ -14,11 +16,16 @@ from orisys_autocal.summary_export import (
POINTER_NAME,
SummaryExportError,
load_summary,
published_summary_directory,
publish_summary,
render_html,
)
def _sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
@pytest.fixture
def session(tmp_path):
root = multipoint_session(
@@ -173,3 +180,85 @@ def test_the_sensitivity_csv_has_one_row_per_point_trial_axis(session):
assert {row["review_status"] for row in rows} == {"candidate"}
fn = [row for row in rows if row["component"] == "FN"]
assert all(float(row["slope_raw_per_N"]) == pytest.approx(40.0) for row in fn)
def test_the_published_files_are_mirrored_under_the_session_root(session):
"""The folder the operator opens is the byte-identical copy the pointer names."""
summary = build_summary(session)
published = publish_summary(session, summary)
pointer = _pointer(session)
flat = session / "summary"
assert flat.is_dir()
assert sorted(path.name for path in flat.iterdir()) == sorted(pointer["files"])
for name, digest in pointer["files"].items():
assert _sha256(flat / name) == digest, name
assert (flat / name).read_bytes() == (published / name).read_bytes(), name
# The readable copy is not a second pointer: the authoritative path is derived/.
assert pointer["path"] == f"derived/{published.name}"
assert published_summary_directory(session) == flat
assert load_summary(session)[1]["summary_id"] == summary["summary_id"]
def test_a_failed_copy_reports_without_moving_the_pointer_back(session, monkeypatch, caplog):
"""Losing the readable copy must not lose the published evidence or the pointer."""
import orisys_autocal.summary_export as module
first = build_summary(session)
publish_summary(session, first)
flat = session / "summary"
before = {path.name: _sha256(path) for path in flat.iterdir()}
first_pointer = _pointer(session)
second = build_summary(session)
second["generated_at"] = "2999-01-01T00:00:00+00:00"
reports: list[str] = []
def explode(*args, **kwargs):
raise OSError("no space left on device")
monkeypatch.setattr(module, "_write_flat_summary", explode)
with caplog.at_level(logging.WARNING, logger="orisys_autocal.summary_export"):
published = publish_summary(session, second, on_warning=reports.append)
pointer = _pointer(session)
assert pointer["summary_id"] == second["summary_id"]
assert pointer["path"] == f"derived/{published.name}"
assert first_pointer["path"] != pointer["path"]
for name, digest in pointer["files"].items():
assert _sha256(published / name) == digest, name
# The previous readable copy still stands, unchanged.
assert {path.name: _sha256(path) for path in flat.iterdir()} == before
assert reports and "no space left on device" in reports[0]
assert any("no space left on device" in record.message for record in caplog.records)
def test_a_stale_readable_copy_is_not_offered_as_the_current_summary(session, monkeypatch):
"""A failed mirror keeps the older copy; opening it must not pass it off as current.
The pointer has moved on, so ``summary/`` describes a superseded version. The
operator must be sent to the directory the pointer names, not to numbers the
pointer no longer claims.
"""
import orisys_autocal.summary_export as module
first = build_summary(session)
publish_summary(session, first)
second = build_summary(session)
second["generated_at"] = "2999-01-01T00:00:00+00:00"
def explode(*args, **kwargs):
raise OSError("no space left on device")
monkeypatch.setattr(module, "_write_flat_summary", explode)
published = publish_summary(session, second)
pointer = _pointer(session)
assert pointer["summary_id"] == second["summary_id"]
opened = published_summary_directory(session)
assert opened is not None
assert opened == published, "the stale summary/ was offered as the current summary"
stored = json.loads((opened / "summary.json").read_text(encoding="utf-8"))
assert stored["summary_id"] == second["summary_id"]
+18 -10
View File
@@ -32,11 +32,19 @@ def _finish_simulated_campaign(campaign) -> None:
def _acquired_hashes(root) -> dict[str, str]:
"""Hashes of the recorded evidence only: derived results are not evidence."""
"""Hashes of the recorded evidence only: export artifacts are not evidence.
The published summary lives in ``derived/`` and its readable mirror in
``summary/``; both are written by the export, so neither is part of the
acquisition and neither may appear in this comparison.
"""
return {
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
for path in root.rglob("*")
if path.is_file() and "derived" not in path.parts and path.name != POINTER_NAME
if path.is_file()
and "derived" not in path.parts
and "summary" not in path.parts
and path.name != POINTER_NAME
}
@@ -78,8 +86,8 @@ def test_a_failed_export_is_reported_and_can_be_retried(
_pump(panel)
assert not (root / POINTER_NAME).exists()
assert "disk full" in panel.result_label.text()
assert panel.trial.status == "idle" # the acquisition result is untouched
assert "disk full" in panel.message.text()
assert panel._directory() == "" # the acquisition result is untouched
monkeypatch.undo()
assert panel.publish_summary_for(root)
@@ -127,16 +135,16 @@ def test_a_published_candidate_is_republished_when_the_review_is_saved(qt_app, t
def test_the_multipoint_end_of_campaign_starts_the_export(make_window, tmp_path):
window = make_window()
panel = window.calibration_panel
multipoint = panel.multipoint
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
directory = multipoint.campaign.start(
campaign = panel.campaign
campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
directory = campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20, simulate=True,
)
_finish_simulated_campaign(multipoint.campaign)
assert multipoint.campaign.phase == "done"
_finish_simulated_campaign(campaign)
assert campaign.phase == "done"
multipoint._poll()
panel._poll()
_pump(panel)
assert (directory / POINTER_NAME).is_file()