重构 Autocal 采集页:单一队列与控制器驱动的主按钮
选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
This commit is contained in:
+31
-32
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。
|
||||
|
||||
检查保持段曲线、样本数、标准差和漂移;起止以保持开始为零,只能在原段内调整。勾选“已检查稳定”后重算并保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合;确认稳定不代表精度合格。
|
||||
|
||||
|
||||
@@ -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 上补跑。
|
||||
|
||||
@@ -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。重复身份、行数不符或缺关联必须报错/排除,不能退回按行号拼接。
|
||||
|
||||
@@ -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 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。
|
||||
|
||||
@@ -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 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 {})
|
||||
|
||||
@@ -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
|
||||
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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,15 +615,24 @@ 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()
|
||||
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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user