autocal: 序列页阵列预览工具、2D 图点可见度,标签顺序与送到标定页
- 标签顺序:几何与安装 放到 测试序列 之前 - 序列点增加可选 uv_px(向后兼容),点表新增 U/V 列;加入所选点、删除所选、 送到标定页(按 U/V 经同一套求解加入标定队列并切换页面) - 已选点结果改显示在选点区状态行,不再写入全局顶部状态条 - 序列页左上区改紧凑:名称与备注同一行,分“阵列”“运动参数”两页; 运动参数每行三组,输入框改用 compactField - 新增阵列工具(array_plan.py、ui/array_tool.py):UV 规则网格(可蛇形遍历)与 阶段预设,逐点检查是否在曲面内、是否留有边距,候选点实时预览在 2D 图上, 只有可用点才能加入,已在序列里的点不重复加入 - 2D 平面图:标记改为缩放后再绘制并加深色描边,修复缩小后点不清; 底图可选 原图/灰度/变暗/仅轮廓,点色可选 黄/白/青/橙/灰(默认灰度+黄,写入偏好); 序列点带编号并连线显示遍历顺序,选中行高亮 - 新增测试 test_sequence_uv、test_sequence_to_capture、test_array_plan、test_array_tool
This commit is contained in:
@@ -494,7 +494,18 @@
|
|||||||
"preview_solve_failed": "Solve failed: {error}",
|
"preview_solve_failed": "Solve failed: {error}",
|
||||||
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
||||||
"load_failed": "Load failed: {error}",
|
"load_failed": "Load failed: {error}",
|
||||||
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°"
|
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°",
|
||||||
|
"bg_label": "Map",
|
||||||
|
"colour_label": "Dots",
|
||||||
|
"bg_original": "Original",
|
||||||
|
"bg_gray": "Grey",
|
||||||
|
"bg_dim": "Dimmed",
|
||||||
|
"bg_outline": "Outline",
|
||||||
|
"colour_yellow": "Yellow",
|
||||||
|
"colour_white": "White",
|
||||||
|
"colour_cyan": "Cyan",
|
||||||
|
"colour_orange": "Orange",
|
||||||
|
"colour_grey": "Grey"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"title": "Test sequence design",
|
"title": "Test sequence design",
|
||||||
@@ -556,7 +567,19 @@
|
|||||||
"imported": "Sequence imported: {name} ({count} points)",
|
"imported": "Sequence imported: {name} ({count} points)",
|
||||||
"import_warnings": "Import reported {count} warning(s)",
|
"import_warnings": "Import reported {count} warning(s)",
|
||||||
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
|
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
|
||||||
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
|
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run.",
|
||||||
|
"col_u": "U (px)",
|
||||||
|
"col_v": "V (px)",
|
||||||
|
"btn_send": "Send to capture ({count})",
|
||||||
|
"send_tip": "Add the selected points to the capture page's queue by U/V and switch to it.",
|
||||||
|
"send_none": "Select rows that have U/V first.",
|
||||||
|
"btn_add_picked": "Add picked point",
|
||||||
|
"add_picked_tip": "Add the point just picked on the 2D map to the sequence, with its U/V.",
|
||||||
|
"btn_remove": "Remove selected",
|
||||||
|
"add_picked_none": "Pick a point on the 2D map first.",
|
||||||
|
"add_picked_duplicate": "That point is already row {row} of the sequence.",
|
||||||
|
"tab_array": "Array",
|
||||||
|
"tab_motion": "Motion"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"ok": "Done",
|
"ok": "Done",
|
||||||
@@ -903,7 +926,8 @@
|
|||||||
"queue_removed": "Point removed: {site}",
|
"queue_removed": "Point removed: {site}",
|
||||||
"skipped": "Point skipped: {site}",
|
"skipped": "Point skipped: {site}",
|
||||||
"stopped": "Stopped; the data already written is kept.",
|
"stopped": "Stopped; the data already written is kept.",
|
||||||
"done": "Session finished: {path}"
|
"done": "Session finished: {path}",
|
||||||
|
"queue_added_many": "{added} point(s) added to the capture queue, {refused} could not be added."
|
||||||
},
|
},
|
||||||
"recording": {
|
"recording": {
|
||||||
"title": "Recording",
|
"title": "Recording",
|
||||||
@@ -974,5 +998,33 @@
|
|||||||
"confirm_title": "Write UNITS",
|
"confirm_title": "Write UNITS",
|
||||||
"confirm_body": "The following UNITS will be written to the controller:\n{changes}\n\nAfterwards every millimetre and degree reading, and every pose-move target, is recomputed with the new equivalent. Confirm that the platform is stopped and the mechanical ratio has been checked.",
|
"confirm_body": "The following UNITS will be written to the controller:\n{changes}\n\nAfterwards every millimetre and degree reading, and every pose-move target, is recomputed with the new equivalent. Confirm that the platform is stopped and the mechanical ratio has been checked.",
|
||||||
"confirm_rotary": "Note: UNITS for {roles} is not the calibration equivalent {cal}; the rotary axis has no angle scale at that value, so pose moves are refused until it is recalibrated."
|
"confirm_rotary": "Note: UNITS for {roles} is not the calibration equivalent {cal}; the rotary axis has no angle scale at that value, so pose moves are refused until it is recalibrated."
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"mode": "Type",
|
||||||
|
"margin": "Margin (px)",
|
||||||
|
"rows": "Rows",
|
||||||
|
"cols": "Columns",
|
||||||
|
"u_pitch": "U pitch (px)",
|
||||||
|
"v_pitch": "V pitch (px)",
|
||||||
|
"centre_u": "Centre U",
|
||||||
|
"centre_v": "Centre V",
|
||||||
|
"preset": "Stage",
|
||||||
|
"mode_grid": "Regular grid",
|
||||||
|
"mode_preset": "Staged preset",
|
||||||
|
"serpentine": "Serpentine order",
|
||||||
|
"centre_from_pick": "Centre on pick",
|
||||||
|
"centre_from_pick_tip": "Use the point just picked on the flat map as the grid centre.",
|
||||||
|
"show_preview": "Preview on the map",
|
||||||
|
"btn_add": "Add to sequence ({count})",
|
||||||
|
"none": "No candidate points.",
|
||||||
|
"all_ok": "{total} candidates, all on the surface.",
|
||||||
|
"some_bad": "{total} candidates: {usable} usable, {off} off the surface, {edge} too near the edge. Only usable points can be added.",
|
||||||
|
"preset_hint": "Staged presets are the suggested starting layout, taken from the planning figure: confirm the hardware with stage 0, then add more. Every point is checked against the current surface; ones off it or inside the margin get a red cross and are not added. A point already in the sequence is not added twice.",
|
||||||
|
"preset_stage0": "Stage 0 · hardware check (1 point)",
|
||||||
|
"preset_stage1": "Stage 1 · calibration 3×3 (9 points)",
|
||||||
|
"preset_stage1_check": "Stage 1 · independent checks (4 points)",
|
||||||
|
"preset_stage2": "Stage 2 · more calibration (8 points)",
|
||||||
|
"preset_stage2_check": "Stage 2 · more checks (4 points)",
|
||||||
|
"added": "{added} point(s) added, {repeated} already in the sequence, {failed} could not be solved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,18 @@
|
|||||||
"preview_solve_failed": "求解失败:{error}",
|
"preview_solve_failed": "求解失败:{error}",
|
||||||
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
||||||
"load_failed": "载入失败: {error}",
|
"load_failed": "载入失败: {error}",
|
||||||
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°"
|
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°",
|
||||||
|
"bg_label": "底图",
|
||||||
|
"colour_label": "点色",
|
||||||
|
"bg_original": "原图",
|
||||||
|
"bg_gray": "灰度",
|
||||||
|
"bg_dim": "变暗",
|
||||||
|
"bg_outline": "仅轮廓",
|
||||||
|
"colour_yellow": "黄",
|
||||||
|
"colour_white": "白",
|
||||||
|
"colour_cyan": "青",
|
||||||
|
"colour_orange": "橙",
|
||||||
|
"colour_grey": "灰"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"title": "测试序列设计",
|
"title": "测试序列设计",
|
||||||
@@ -556,7 +567,19 @@
|
|||||||
"imported": "已导入序列: {name}({count} 点)",
|
"imported": "已导入序列: {name}({count} 点)",
|
||||||
"import_warnings": "导入有 {count} 项提示",
|
"import_warnings": "导入有 {count} 项提示",
|
||||||
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
|
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
|
||||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
|
||||||
|
"col_u": "U (px)",
|
||||||
|
"col_v": "V (px)",
|
||||||
|
"btn_send": "送到标定页({count})",
|
||||||
|
"send_tip": "把选中的点按 U/V 加入标定采集页的点队列,并切换到该页。",
|
||||||
|
"send_none": "先选中带 U/V 的行。",
|
||||||
|
"btn_add_picked": "加入所选点",
|
||||||
|
"add_picked_tip": "把平面图上刚点选的点加入序列(带 U/V)。",
|
||||||
|
"btn_remove": "删除所选",
|
||||||
|
"add_picked_none": "先在平面图上点选一个点。",
|
||||||
|
"add_picked_duplicate": "该点已在序列第 {row} 行。",
|
||||||
|
"tab_array": "阵列",
|
||||||
|
"tab_motion": "运动参数"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"ok": "完成",
|
"ok": "完成",
|
||||||
@@ -903,7 +926,8 @@
|
|||||||
"queue_removed": "已删除测点 {site}",
|
"queue_removed": "已删除测点 {site}",
|
||||||
"skipped": "已跳过测点 {site}",
|
"skipped": "已跳过测点 {site}",
|
||||||
"stopped": "已停止,已写入的数据保留。",
|
"stopped": "已停止,已写入的数据保留。",
|
||||||
"done": "本场标定已结束:{path}"
|
"done": "本场标定已结束:{path}",
|
||||||
|
"queue_added_many": "已加入 {added} 个点到标定队列,{refused} 个未能加入。"
|
||||||
},
|
},
|
||||||
"recording": {
|
"recording": {
|
||||||
"title": "录像",
|
"title": "录像",
|
||||||
@@ -974,5 +998,33 @@
|
|||||||
"confirm_title": "写入 UNITS",
|
"confirm_title": "写入 UNITS",
|
||||||
"confirm_body": "将向控制器写入以下 UNITS:\n{changes}\n\n写入后,全部毫米和角度读数以及姿态移动的目标都按新当量重新计算。请确认平台已停止,并已核对机械传动比。",
|
"confirm_body": "将向控制器写入以下 UNITS:\n{changes}\n\n写入后,全部毫米和角度读数以及姿态移动的目标都按新当量重新计算。请确认平台已停止,并已核对机械传动比。",
|
||||||
"confirm_rotary": "注意:{roles} 的 UNITS 不是标定当量 {cal},旋转轴在该当量下没有角度标定,姿态移动会被拒绝,直到重新标定。"
|
"confirm_rotary": "注意:{roles} 的 UNITS 不是标定当量 {cal},旋转轴在该当量下没有角度标定,姿态移动会被拒绝,直到重新标定。"
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"mode": "类型",
|
||||||
|
"margin": "边距 (px)",
|
||||||
|
"rows": "行数",
|
||||||
|
"cols": "列数",
|
||||||
|
"u_pitch": "U 间距 (px)",
|
||||||
|
"v_pitch": "V 间距 (px)",
|
||||||
|
"centre_u": "中心 U",
|
||||||
|
"centre_v": "中心 V",
|
||||||
|
"preset": "阶段",
|
||||||
|
"mode_grid": "规则网格",
|
||||||
|
"mode_preset": "阶段预设",
|
||||||
|
"serpentine": "蛇形遍历",
|
||||||
|
"centre_from_pick": "取选点为中心",
|
||||||
|
"centre_from_pick_tip": "把平面图上刚点选的点作为网格中心。",
|
||||||
|
"show_preview": "在平面图上预览",
|
||||||
|
"btn_add": "加入序列({count})",
|
||||||
|
"none": "没有候选点。",
|
||||||
|
"all_ok": "{total} 个候选点,全部落在曲面内。",
|
||||||
|
"some_bad": "{total} 个候选点:可用 {usable},曲面外 {off},太靠边 {edge}。只有可用的点能加入。",
|
||||||
|
"preset_hint": "阶段预设是建议的起始布点,坐标取自规划图;先用阶段 0 确认硬件,再逐级加密。每个点都会按当前曲面检查,落在曲面外或边距内的会标红叉,不会被加入。已在序列里的点不会重复添加。",
|
||||||
|
"preset_stage0": "阶段 0 · 硬件自检(1 点)",
|
||||||
|
"preset_stage1": "阶段 1 · 标定 3×3(9 点)",
|
||||||
|
"preset_stage1_check": "阶段 1 · 独立验证(4 点)",
|
||||||
|
"preset_stage2": "阶段 2 · 标定加点(8 点)",
|
||||||
|
"preset_stage2_check": "阶段 2 · 验证加点(4 点)",
|
||||||
|
"added": "已加入 {added} 个点,{repeated} 个已在序列里,{failed} 个求解失败。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ to another bench's number.
|
|||||||
| `id` | 1-based, stable within the file; used for run progress and resume |
|
| `id` | 1-based, stable within the file; used for run progress and resume |
|
||||||
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
|
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
|
||||||
| `sensor_mm` | where that point lands on the sensor; optional but written |
|
| `sensor_mm` | where that point lands on the sensor; optional but written |
|
||||||
|
| `uv_px` | `{u, v}`: the point's place on the flat map, in pixels from the map origin; optional (added without a version change, older files simply lack it). The capture page takes its points by U/V, so a point with `uv_px` can be sent there directly |
|
||||||
| `clearance_mm` | the standoff for this point |
|
| `clearance_mm` | the standoff for this point |
|
||||||
| `enabled` | false = designed but deliberately skipped |
|
| `enabled` | false = designed but deliberately skipped |
|
||||||
| `note` | free text |
|
| `note` | free text |
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Candidate calibration points in U/V, and the check that each one is usable.
|
||||||
|
|
||||||
|
A sequence is designed on the flat map, so the natural coordinates are the map's
|
||||||
|
own: ``U`` and ``V`` in pixels from the map origin (the same numbers the capture
|
||||||
|
page takes and the point table shows). This module makes arrays of them and says,
|
||||||
|
for each, whether the surface is really there. It imports no Qt, so the
|
||||||
|
arithmetic is tested without a display.
|
||||||
|
|
||||||
|
Two sources of points:
|
||||||
|
|
||||||
|
* a **regular grid** around a centre, optionally walked in a serpentine so the
|
||||||
|
stage does not fly back across the patch after every row;
|
||||||
|
* **staged presets** — a deliberately small first set that proves the hardware, then
|
||||||
|
larger sets added only once the smaller one behaves. The U/V of the presets are
|
||||||
|
the suggested starting layout, read off the planning figure; the check below is
|
||||||
|
what decides whether each one is usable on the loaded surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .sensor.uv_flat import uv_origin
|
||||||
|
|
||||||
|
#: Why a candidate is unusable. Empty for a usable one.
|
||||||
|
OFF_SURFACE = "off_surface"
|
||||||
|
NEAR_EDGE = "near_edge"
|
||||||
|
|
||||||
|
#: Largest array one request may build; a typo in a pitch must not freeze the UI.
|
||||||
|
MAX_POINTS = 400
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
"""One proposed point and whether the surface supports it."""
|
||||||
|
|
||||||
|
u: int
|
||||||
|
v: int
|
||||||
|
valid: bool = True
|
||||||
|
reason: str = ""
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Suggested layouts, ``name -> ((label, u, v), ...)``. Staged on purpose: later
|
||||||
|
#: sets are *added* to earlier ones. The one overlap is C13, the stage-0 point, which
|
||||||
|
#: is also the centre of the stage-1 grid; a point already in the sequence is not
|
||||||
|
#: added twice, so adding stage 1 after stage 0 simply skips it.
|
||||||
|
PRESETS: dict[str, tuple[tuple[str, int, int], ...]] = {
|
||||||
|
# Hardware check: one interior point, away from every edge.
|
||||||
|
"stage0": (("C13", 0, 470),),
|
||||||
|
# First model: a centred 3 x 3, all well inside the mapped area.
|
||||||
|
"stage1": (
|
||||||
|
("C06", -165, 320), ("C07", 0, 320), ("C08", 165, 320),
|
||||||
|
("C12", -130, 470), ("C13", 0, 470), ("C14", 130, 470),
|
||||||
|
("C18", -195, 620), ("C19", 0, 620), ("C20", 195, 620),
|
||||||
|
),
|
||||||
|
# Independent checks for stage 1: between the model's points, never on them.
|
||||||
|
"stage1_check": (
|
||||||
|
("V06", -240, 395), ("V07", 0, 395), ("V08", 240, 395), ("V10", 0, 545),
|
||||||
|
),
|
||||||
|
# Stage 2 additions: towards the edges and the top and bottom.
|
||||||
|
"stage2": (
|
||||||
|
("C02", -260, 180), ("C03", 0, 180), ("C04", 260, 180),
|
||||||
|
("C11", -260, 470), ("C15", 260, 470),
|
||||||
|
("C22", -250, 750), ("C23", 0, 750), ("C24", 250, 750),
|
||||||
|
),
|
||||||
|
"stage2_check": (
|
||||||
|
("V03", -260, 250), ("V04", 0, 250), ("V05", 260, 250), ("V13", 0, 685),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
PRESET_KEYS: tuple[str, ...] = tuple(PRESETS)
|
||||||
|
|
||||||
|
|
||||||
|
def grid_uv(
|
||||||
|
rows: int,
|
||||||
|
cols: int,
|
||||||
|
u_pitch: float,
|
||||||
|
v_pitch: float,
|
||||||
|
centre: tuple[float, float] = (0.0, 0.0),
|
||||||
|
*,
|
||||||
|
serpentine: bool = True,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
"""A ``rows x cols`` grid centred on ``centre``, bottom row first.
|
||||||
|
|
||||||
|
Rows run along U. With ``serpentine`` every second row is walked backwards, so
|
||||||
|
the visiting order is a path with no long return strokes. An oversized request
|
||||||
|
raises instead of building it: the preview redraws on every keystroke.
|
||||||
|
"""
|
||||||
|
rows, cols = int(rows), int(cols)
|
||||||
|
if rows < 1 or cols < 1:
|
||||||
|
return []
|
||||||
|
if rows * cols > MAX_POINTS:
|
||||||
|
raise ValueError(f"{rows} x {cols} points is more than the {MAX_POINTS} allowed")
|
||||||
|
points: list[tuple[int, int]] = []
|
||||||
|
for row in range(rows):
|
||||||
|
v = centre[1] + (row - (rows - 1) / 2.0) * float(v_pitch)
|
||||||
|
columns = [
|
||||||
|
centre[0] + (col - (cols - 1) / 2.0) * float(u_pitch) for col in range(cols)
|
||||||
|
]
|
||||||
|
if serpentine and row % 2 == 1:
|
||||||
|
columns.reverse()
|
||||||
|
points.extend((int(round(u)), int(round(v))) for u in columns)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def preset_uv(key: str) -> list[tuple[str, int, int]]:
|
||||||
|
"""The labelled points of one preset; an unknown name is an error."""
|
||||||
|
try:
|
||||||
|
return list(PRESETS[key])
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"no such preset: {key!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def check_candidates(
|
||||||
|
flat,
|
||||||
|
points: Sequence[tuple[int, int] | tuple[str, int, int]],
|
||||||
|
margin_px: int = 0,
|
||||||
|
*,
|
||||||
|
origin: tuple[int, int] | None = None,
|
||||||
|
) -> list[Candidate]:
|
||||||
|
"""Mark each ``(u, v)`` (or ``(label, u, v)``) usable or not on this surface.
|
||||||
|
|
||||||
|
Unusable means the pixel is not on the mapped surface, or — when a margin is
|
||||||
|
asked for — that the mapped area does not extend ``margin_px`` pixels around it
|
||||||
|
in every direction. A press near the edge lands partly off the sensor, which is
|
||||||
|
the thing the margin exists to avoid. With no surface loaded nothing can be
|
||||||
|
vouched for, so every point is reported off-surface.
|
||||||
|
"""
|
||||||
|
if flat is None:
|
||||||
|
return [_candidate(point, False, OFF_SURFACE) for point in points]
|
||||||
|
covered = np.asarray(flat.face_index) >= 0
|
||||||
|
height, width = covered.shape
|
||||||
|
origin_x, origin_y = origin if origin is not None else uv_origin(flat)
|
||||||
|
margin = max(0, int(margin_px))
|
||||||
|
found: list[Candidate] = []
|
||||||
|
for point in points:
|
||||||
|
u, v = _uv(point)
|
||||||
|
x, y = origin_x + u, origin_y - v
|
||||||
|
if not (0 <= x < width and 0 <= y < height) or not covered[y, x]:
|
||||||
|
found.append(_candidate(point, False, OFF_SURFACE))
|
||||||
|
continue
|
||||||
|
low_x, high_x = x - margin, x + margin + 1
|
||||||
|
low_y, high_y = y - margin, y + margin + 1
|
||||||
|
inside = low_x >= 0 and low_y >= 0 and high_x <= width and high_y <= height
|
||||||
|
if margin and (not inside or not covered[low_y:high_y, low_x:high_x].all()):
|
||||||
|
found.append(_candidate(point, False, NEAR_EDGE))
|
||||||
|
continue
|
||||||
|
found.append(_candidate(point, True, ""))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _uv(point) -> tuple[int, int]:
|
||||||
|
if len(point) == 3:
|
||||||
|
return int(point[1]), int(point[2])
|
||||||
|
return int(point[0]), int(point[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(point, valid: bool, reason: str) -> Candidate:
|
||||||
|
label = str(point[0]) if len(point) == 3 else ""
|
||||||
|
u, v = _uv(point)
|
||||||
|
return Candidate(u=u, v=v, valid=valid, reason=reason, label=label)
|
||||||
@@ -275,6 +275,10 @@ def default_preferences() -> dict[str, Any]:
|
|||||||
#: Where the tool is, in the table frame, mm. A tool-tip offset belongs
|
#: Where the tool is, in the table frame, mm. A tool-tip offset belongs
|
||||||
#: here. The bench X shift is the initial position, not this number.
|
#: here. The bench X shift is the initial position, not this number.
|
||||||
"finger_tool_xy_mm": [0.0, 3.0],
|
"finger_tool_xy_mm": [0.0, 3.0],
|
||||||
|
# How the 2D map and its dots are drawn: a grey map with yellow dots reads
|
||||||
|
# on the multi-coloured texture where the original does not.
|
||||||
|
"finger_view_background": "gray",
|
||||||
|
"finger_marker_colour": "yellow",
|
||||||
#: Where 「回初始位」 parks the table, mm. After the power cycle the
|
#: Where 「回初始位」 parks the table, mm. After the power cycle the
|
||||||
#: coordinate origin sits on the previous park, so this is the origin:
|
#: coordinate origin sits on the previous park, so this is the origin:
|
||||||
#: one millimetre back in X and 15 mm back in Y from the old target.
|
#: one millimetre back in X and 15 mm back in Y from the old target.
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ class SequencePoint:
|
|||||||
id: int
|
id: int
|
||||||
target_mm: dict[str, float]
|
target_mm: dict[str, float]
|
||||||
sensor_mm: dict[str, float] | None = None
|
sensor_mm: dict[str, float] | None = None
|
||||||
|
#: Where the point is on the flat map (AutoCal's U/V, pixels from the map
|
||||||
|
#: origin), as ``{"u": .., "v": ..}``. Optional: the capture page takes its
|
||||||
|
#: points by U/V, so a point that has them can be sent there without a search.
|
||||||
|
uv_px: dict[str, float] | None = None
|
||||||
clearance_mm: float = 0.0
|
clearance_mm: float = 0.0
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
note: str = ""
|
note: str = ""
|
||||||
@@ -106,6 +110,8 @@ class SequencePoint:
|
|||||||
}
|
}
|
||||||
if self.sensor_mm is not None:
|
if self.sensor_mm is not None:
|
||||||
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
|
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
|
||||||
|
if self.uv_px is not None:
|
||||||
|
payload["uv_px"] = {axis: float(self.uv_px.get(axis, 0.0)) for axis in "uv"}
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -414,6 +420,7 @@ def _parse_points(
|
|||||||
if axis not in target:
|
if axis not in target:
|
||||||
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
|
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
|
||||||
sensor = entry.get("sensor_mm")
|
sensor = entry.get("sensor_mm")
|
||||||
|
uv = entry.get("uv_px")
|
||||||
points.append(
|
points.append(
|
||||||
SequencePoint(
|
SequencePoint(
|
||||||
id=int(_number(entry.get("id"), index)),
|
id=int(_number(entry.get("id"), index)),
|
||||||
@@ -423,6 +430,11 @@ def _parse_points(
|
|||||||
if isinstance(sensor, dict)
|
if isinstance(sensor, dict)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
uv_px=(
|
||||||
|
{axis: _number(uv.get(axis), 0.0) for axis in "uv"}
|
||||||
|
if isinstance(uv, dict) and "u" in uv and "v" in uv
|
||||||
|
else None
|
||||||
|
),
|
||||||
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
|
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
|
||||||
enabled=bool(entry.get("enabled", True)),
|
enabled=bool(entry.get("enabled", True)),
|
||||||
note=str(entry.get("note") or ""),
|
note=str(entry.get("note") or ""),
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""The array tool of the sequence page: propose points on the map, see them, add them.
|
||||||
|
|
||||||
|
It is a small form and nothing more. The tool turns its fields into a list of U/V
|
||||||
|
candidates (``array_plan``) and announces it; the window checks each one against the
|
||||||
|
loaded surface, draws them on the 2D map, and hands the verdict back through
|
||||||
|
:meth:`ArrayTool.set_checked`. Only candidates the surface supports can be added, so
|
||||||
|
a sequence never receives a point nothing has checked — the rule the point table's
|
||||||
|
hint states.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..array_plan import (
|
||||||
|
NEAR_EDGE,
|
||||||
|
OFF_SURFACE,
|
||||||
|
PRESET_KEYS,
|
||||||
|
Candidate,
|
||||||
|
grid_uv,
|
||||||
|
preset_uv,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODES: tuple[str, ...] = ("grid", "preset")
|
||||||
|
|
||||||
|
#: Defaults for a first look: a 3 x 3 centred on the middle of the patch.
|
||||||
|
_DEFAULTS = {
|
||||||
|
"rows": 3, "cols": 3, "u_pitch": 130, "v_pitch": 150,
|
||||||
|
"centre_u": 0, "centre_v": 470, "margin": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayTool(QWidget):
|
||||||
|
"""Grid or staged-preset candidates, previewed on the map before they are added."""
|
||||||
|
|
||||||
|
#: ``(points, margin_px)``: raw candidates, to be checked against the surface.
|
||||||
|
#: Always sent, whether or not they are drawn: the count and the add button need
|
||||||
|
#: the verdict either way.
|
||||||
|
previewChanged = Signal(object, int)
|
||||||
|
#: The usable candidates, as :class:`~orisys_autocal.array_plan.Candidate`.
|
||||||
|
addRequested = Signal(object)
|
||||||
|
#: Take the point picked on the map as the grid's centre.
|
||||||
|
centreFromPickRequested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._checked: list[Candidate] = []
|
||||||
|
self._spins: dict[str, QSpinBox] = {}
|
||||||
|
self._labels: dict[str, QLabel] = {}
|
||||||
|
|
||||||
|
column = QVBoxLayout(self)
|
||||||
|
column.setContentsMargins(4, 6, 4, 4)
|
||||||
|
column.setSpacing(6)
|
||||||
|
|
||||||
|
top = QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
|
self.mode_combo = self._combo()
|
||||||
|
for mode in MODES:
|
||||||
|
self.mode_combo.addItem("", mode)
|
||||||
|
self._labels["mode"] = QLabel(self)
|
||||||
|
self._labels["margin"] = QLabel(self)
|
||||||
|
top.addWidget(self._labels["mode"])
|
||||||
|
top.addWidget(self.mode_combo, 1)
|
||||||
|
top.addWidget(self._labels["margin"])
|
||||||
|
top.addWidget(self._spin("margin", 0, 200), 0)
|
||||||
|
column.addLayout(top)
|
||||||
|
|
||||||
|
self.pages = QStackedWidget(self)
|
||||||
|
self.pages.addWidget(self._build_grid_page())
|
||||||
|
self.pages.addWidget(self._build_preset_page())
|
||||||
|
column.addWidget(self.pages)
|
||||||
|
|
||||||
|
self.info_label = QLabel(self)
|
||||||
|
self.info_label.setObjectName("statusValue")
|
||||||
|
self.info_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.info_label)
|
||||||
|
column.addStretch(1)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.setSpacing(8)
|
||||||
|
self.show_check = QCheckBox(self)
|
||||||
|
self.show_check.setChecked(True)
|
||||||
|
self.show_check.toggled.connect(self._emit_preview)
|
||||||
|
self.add_button = QPushButton(self)
|
||||||
|
self.add_button.setObjectName("primaryButton")
|
||||||
|
self.add_button.clicked.connect(self._on_add)
|
||||||
|
buttons.addWidget(self.show_check)
|
||||||
|
buttons.addStretch(1)
|
||||||
|
buttons.addWidget(self.add_button)
|
||||||
|
column.addLayout(buttons)
|
||||||
|
|
||||||
|
self.mode_combo.currentIndexChanged.connect(self._on_mode)
|
||||||
|
self.preset_combo.currentIndexChanged.connect(self._emit_preview)
|
||||||
|
self.serpentine_check.toggled.connect(self._emit_preview)
|
||||||
|
for spin in self._spins.values():
|
||||||
|
spin.valueChanged.connect(self._emit_preview)
|
||||||
|
self.retranslate()
|
||||||
|
self._sync_add()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ widgets
|
||||||
|
|
||||||
|
def _combo(self) -> QComboBox:
|
||||||
|
combo = QComboBox(self)
|
||||||
|
combo.setObjectName("compactField")
|
||||||
|
combo.setFixedHeight(26)
|
||||||
|
return combo
|
||||||
|
|
||||||
|
def _spin(self, key: str, low: int, high: int) -> QSpinBox:
|
||||||
|
spin = QSpinBox(self)
|
||||||
|
spin.setObjectName("compactField")
|
||||||
|
spin.setRange(low, high)
|
||||||
|
spin.setValue(_DEFAULTS.get(key, 0))
|
||||||
|
spin.setFixedHeight(26)
|
||||||
|
spin.setMinimumWidth(64)
|
||||||
|
self._spins[key] = spin
|
||||||
|
return spin
|
||||||
|
|
||||||
|
def _build_grid_page(self) -> QWidget:
|
||||||
|
page = QWidget(self)
|
||||||
|
grid = QGridLayout(page)
|
||||||
|
grid.setContentsMargins(0, 0, 0, 0)
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
grid.setVerticalSpacing(6)
|
||||||
|
specs = (
|
||||||
|
("rows", 1, 20), ("cols", 1, 20),
|
||||||
|
("u_pitch", 1, 1500), ("v_pitch", 1, 1500),
|
||||||
|
("centre_u", -1500, 1500), ("centre_v", -1500, 1500),
|
||||||
|
)
|
||||||
|
for index, (key, low, high) in enumerate(specs):
|
||||||
|
row, side = divmod(index, 2)
|
||||||
|
self._labels[key] = QLabel(page)
|
||||||
|
grid.addWidget(self._labels[key], row, side * 2)
|
||||||
|
grid.addWidget(self._spin(key, low, high), row, side * 2 + 1)
|
||||||
|
self.serpentine_check = QCheckBox(page)
|
||||||
|
self.serpentine_check.setChecked(True)
|
||||||
|
self.centre_button = QPushButton(page)
|
||||||
|
self.centre_button.setProperty("rail", True)
|
||||||
|
self.centre_button.clicked.connect(self.centreFromPickRequested)
|
||||||
|
grid.addWidget(self.serpentine_check, 3, 0, 1, 2)
|
||||||
|
grid.addWidget(self.centre_button, 3, 2, 1, 2)
|
||||||
|
grid.setColumnStretch(1, 1)
|
||||||
|
grid.setColumnStretch(3, 1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _build_preset_page(self) -> QWidget:
|
||||||
|
page = QWidget(self)
|
||||||
|
column = QVBoxLayout(page)
|
||||||
|
column.setContentsMargins(0, 0, 0, 0)
|
||||||
|
column.setSpacing(6)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self._labels["preset"] = QLabel(page)
|
||||||
|
self.preset_combo = self._combo()
|
||||||
|
for key in PRESET_KEYS:
|
||||||
|
self.preset_combo.addItem("", key)
|
||||||
|
row.addWidget(self._labels["preset"])
|
||||||
|
row.addWidget(self.preset_combo, 1)
|
||||||
|
column.addLayout(row)
|
||||||
|
self.preset_hint = QLabel(page)
|
||||||
|
self.preset_hint.setObjectName("hintLabel")
|
||||||
|
self.preset_hint.setWordWrap(True)
|
||||||
|
column.addWidget(self.preset_hint)
|
||||||
|
column.addStretch(1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
return str(self.mode_combo.currentData() or MODES[0])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def margin_px(self) -> int:
|
||||||
|
return int(self._spins["margin"].value())
|
||||||
|
|
||||||
|
def spin(self, key: str) -> QSpinBox:
|
||||||
|
return self._spins[key]
|
||||||
|
|
||||||
|
def set_centre(self, u: int, v: int) -> None:
|
||||||
|
for key, value in (("centre_u", u), ("centre_v", v)):
|
||||||
|
self._spins[key].setValue(int(round(value)))
|
||||||
|
|
||||||
|
def candidates(self) -> list[tuple]:
|
||||||
|
"""The raw proposed points for the current fields, ``(u, v)`` or ``(label, u, v)``."""
|
||||||
|
if self.mode == "preset":
|
||||||
|
return preset_uv(str(self.preset_combo.currentData()))
|
||||||
|
return grid_uv(
|
||||||
|
self._spins["rows"].value(),
|
||||||
|
self._spins["cols"].value(),
|
||||||
|
self._spins["u_pitch"].value(),
|
||||||
|
self._spins["v_pitch"].value(),
|
||||||
|
(self._spins["centre_u"].value(), self._spins["centre_v"].value()),
|
||||||
|
serpentine=self.serpentine_check.isChecked(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def previewing(self) -> bool:
|
||||||
|
return self.show_check.isChecked()
|
||||||
|
|
||||||
|
def usable(self) -> list[Candidate]:
|
||||||
|
return [c for c in self._checked if c.valid]
|
||||||
|
|
||||||
|
def set_checked(self, checked: list[Candidate]) -> None:
|
||||||
|
"""The surface's verdict on the current candidates; updates the count and the button."""
|
||||||
|
self._checked = list(checked)
|
||||||
|
usable = len(self.usable())
|
||||||
|
off = sum(1 for c in checked if c.reason == OFF_SURFACE)
|
||||||
|
edge = sum(1 for c in checked if c.reason == NEAR_EDGE)
|
||||||
|
if not checked:
|
||||||
|
self.info_label.setText(t("array.none"))
|
||||||
|
elif not (off or edge):
|
||||||
|
self.info_label.setText(t("array.all_ok", total=len(checked)))
|
||||||
|
else:
|
||||||
|
self.info_label.setText(
|
||||||
|
t("array.some_bad", total=len(checked), usable=usable, off=off, edge=edge)
|
||||||
|
)
|
||||||
|
self._sync_add()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- behaviour
|
||||||
|
|
||||||
|
def _on_mode(self, *_args) -> None:
|
||||||
|
self.pages.setCurrentIndex(MODES.index(self.mode))
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *_args) -> None:
|
||||||
|
try:
|
||||||
|
points = self.candidates()
|
||||||
|
except ValueError:
|
||||||
|
points = []
|
||||||
|
self.previewChanged.emit(points, self.margin_px)
|
||||||
|
self._sync_add()
|
||||||
|
|
||||||
|
def _on_add(self) -> None:
|
||||||
|
usable = self.usable()
|
||||||
|
if usable:
|
||||||
|
self.addRequested.emit(usable)
|
||||||
|
|
||||||
|
def _sync_add(self) -> None:
|
||||||
|
count = len(self.usable())
|
||||||
|
self.add_button.setEnabled(count > 0)
|
||||||
|
self.add_button.setText(t("array.btn_add", count=count))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
for key, label in self._labels.items():
|
||||||
|
label.setText(t(f"array.{key}"))
|
||||||
|
for index, mode in enumerate(MODES):
|
||||||
|
self.mode_combo.setItemText(index, t(f"array.mode_{mode}"))
|
||||||
|
for index, key in enumerate(PRESET_KEYS):
|
||||||
|
self.preset_combo.setItemText(index, t(f"array.preset_{key}"))
|
||||||
|
self.preset_hint.setText(t("array.preset_hint"))
|
||||||
|
self.serpentine_check.setText(t("array.serpentine"))
|
||||||
|
self.centre_button.setText(t("array.centre_from_pick"))
|
||||||
|
self.centre_button.setToolTip(t("array.centre_from_pick_tip"))
|
||||||
|
self.show_check.setText(t("array.show_preview"))
|
||||||
|
self._sync_add()
|
||||||
@@ -1058,6 +1058,67 @@ class CalibrationPanel(QWidget):
|
|||||||
self.set_message(t("cap.message.queue_added", site=site_id))
|
self.set_message(t("cap.message.queue_added", site=site_id))
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
|
def add_uv_points(self, uvs: list[tuple[float, float]]) -> tuple[int, int]:
|
||||||
|
"""Queue points given by U/V, with the page's current levels, hold and repeats.
|
||||||
|
|
||||||
|
The sequence page designs the points and this is where they are taken: each
|
||||||
|
goes through the same solve and the same ``controller.add_point`` as a point
|
||||||
|
added by hand, so a sequence row cannot reach the queue by any other road.
|
||||||
|
Returns ``(added, refused)``; a frozen queue refuses everything at once.
|
||||||
|
"""
|
||||||
|
added = refused = 0
|
||||||
|
try:
|
||||||
|
controller = self._ensure_controller()
|
||||||
|
levels = self._levels()
|
||||||
|
hold = self._hold_value()
|
||||||
|
existing = [row.site_id for row in controller.model().sites]
|
||||||
|
except ActionBlocked as exc:
|
||||||
|
self.set_message(t(exc.reason))
|
||||||
|
self._refresh()
|
||||||
|
return 0, len(uvs)
|
||||||
|
except (OSError, RuntimeError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
self.set_message(t("calibration.error").format(detail=exc))
|
||||||
|
self._refresh()
|
||||||
|
return 0, len(uvs)
|
||||||
|
problem = ""
|
||||||
|
site_id = ""
|
||||||
|
for u, v in uvs:
|
||||||
|
try:
|
||||||
|
point, normal, pose, uv = self._solve_uv(float(u), float(v))
|
||||||
|
site_id = next_point_id(existing)
|
||||||
|
controller.add_point(
|
||||||
|
site_id,
|
||||||
|
point[0],
|
||||||
|
point[1],
|
||||||
|
point[2],
|
||||||
|
normal,
|
||||||
|
pose,
|
||||||
|
levels,
|
||||||
|
hold,
|
||||||
|
self.repeats.value(),
|
||||||
|
uv_px=uv,
|
||||||
|
)
|
||||||
|
except ActionBlocked as exc:
|
||||||
|
problem = t(exc.reason)
|
||||||
|
refused += len(uvs) - added - refused
|
||||||
|
break
|
||||||
|
except (OSError, RuntimeError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
problem = t("calibration.error").format(detail=exc)
|
||||||
|
refused += 1
|
||||||
|
continue
|
||||||
|
existing.append(site_id)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
self._selected_site = site_id
|
||||||
|
self._clear_live_reach()
|
||||||
|
self.site_id.setText(next_point_id(existing))
|
||||||
|
if problem and not added:
|
||||||
|
self.set_message(problem)
|
||||||
|
else:
|
||||||
|
self.set_message(t("cap.message.queue_added_many", added=added, refused=refused))
|
||||||
|
self._refresh()
|
||||||
|
return added, refused
|
||||||
|
|
||||||
def _update_point(self) -> None:
|
def _update_point(self) -> None:
|
||||||
"""Retake the selected point at the current UV position.
|
"""Retake the selected point at the current UV position.
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import QPoint, Qt, Signal
|
from PySide6.QtCore import QPointF, Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPainterPath, QPen, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
@@ -55,6 +56,18 @@ from ..sensor.uv_flat import FlatSurface, uv_origin
|
|||||||
#: this is a pixel and a half — finer than the view can render anyway.
|
#: this is a pixel and a half — finer than the view can render anyway.
|
||||||
_POINT_MATCH_MM = 0.02
|
_POINT_MATCH_MM = 0.02
|
||||||
|
|
||||||
|
#: How the flat map can be drawn behind the marks, and the colours the marks can take.
|
||||||
|
#: The texture is multi-coloured, so no single mark colour reads on all of it; the
|
||||||
|
#: operator picks the pairing that works for the patch in front of them.
|
||||||
|
BACKGROUNDS: tuple[str, ...] = ("original", "gray", "dim", "outline")
|
||||||
|
MARKER_COLOURS: tuple[tuple[str, str], ...] = (
|
||||||
|
("yellow", "#ffd60a"),
|
||||||
|
("white", "#ffffff"),
|
||||||
|
("cyan", "#32d7ff"),
|
||||||
|
("orange", "#ff9f0a"),
|
||||||
|
("grey", "#b8b8c0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FlatSurfaceView(QLabel):
|
class FlatSurfaceView(QLabel):
|
||||||
"""The flat image, clickable, with the picked pixel cross-haired.
|
"""The flat image, clickable, with the picked pixel cross-haired.
|
||||||
@@ -80,6 +93,13 @@ class FlatSurfaceView(QLabel):
|
|||||||
self._pixmap: QPixmap | None = None
|
self._pixmap: QPixmap | None = None
|
||||||
self._marker: tuple[int, int] | None = None
|
self._marker: tuple[int, int] | None = None
|
||||||
self._selected: tuple[int, int] | None = None
|
self._selected: tuple[int, int] | None = None
|
||||||
|
self._background = BACKGROUNDS[0]
|
||||||
|
self._marker_colour = QColor(MARKER_COLOURS[0][1])
|
||||||
|
self._sequence: list[tuple[int, int, str, bool]] = []
|
||||||
|
self._candidates: list[tuple[int, int, bool]] = []
|
||||||
|
self._show_path = True
|
||||||
|
#: Background name -> native-size pixmap, rebuilt when the map changes.
|
||||||
|
self._bases: dict[str, QPixmap] = {}
|
||||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -90,6 +110,7 @@ class FlatSurfaceView(QLabel):
|
|||||||
self._flat = flat
|
self._flat = flat
|
||||||
self._marker = None
|
self._marker = None
|
||||||
self._selected = None
|
self._selected = None
|
||||||
|
self._bases = {}
|
||||||
if flat is None:
|
if flat is None:
|
||||||
self._pixmap = None
|
self._pixmap = None
|
||||||
self.clear()
|
self.clear()
|
||||||
@@ -143,55 +164,173 @@ class FlatSurfaceView(QLabel):
|
|||||||
return None
|
return None
|
||||||
return int(columns[best]), int(rows[best])
|
return int(columns[best]), int(rows[best])
|
||||||
|
|
||||||
def _marked(self) -> QPixmap | None:
|
# ------------------------------------------------------------------ styling
|
||||||
"""The native-size image with both markers drawn on it, or ``None``."""
|
|
||||||
if self._pixmap is None:
|
def set_background(self, mode: str) -> None:
|
||||||
|
"""How the map itself is drawn, so the dots have something to stand out from."""
|
||||||
|
if mode not in BACKGROUNDS:
|
||||||
|
mode = BACKGROUNDS[0]
|
||||||
|
if mode != self._background:
|
||||||
|
self._background = mode
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def background(self) -> str:
|
||||||
|
return self._background
|
||||||
|
|
||||||
|
def set_marker_colour(self, colour: QColor | str) -> None:
|
||||||
|
self._marker_colour = QColor(colour)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marker_colour(self) -> QColor:
|
||||||
|
return QColor(self._marker_colour)
|
||||||
|
|
||||||
|
def set_sequence_points(self, points: list[tuple[int, int, str, bool]]) -> None:
|
||||||
|
"""The sequence's points as ``(x_px, y_px, label, selected)`` on the raster."""
|
||||||
|
self._sequence = list(points)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def set_candidates(self, candidates: list[tuple[int, int, bool]]) -> None:
|
||||||
|
"""Proposed points as ``(x_px, y_px, usable)``: previewed, not yet in the list."""
|
||||||
|
self._candidates = list(candidates)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def set_show_path(self, show: bool) -> None:
|
||||||
|
self._show_path = bool(show)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _base(self) -> QPixmap | None:
|
||||||
|
"""The map in the chosen background, native size, without any marks."""
|
||||||
|
if self._flat is None or self._pixmap is None:
|
||||||
return None
|
return None
|
||||||
if self._marker is None and self._selected is None:
|
cached = self._bases.get(self._background)
|
||||||
return self._pixmap
|
if cached is not None:
|
||||||
canvas = self._pixmap.copy()
|
return cached
|
||||||
|
image = self._flat.image_rgb
|
||||||
|
if self._background == "original":
|
||||||
|
pixmap = self._pixmap
|
||||||
|
else:
|
||||||
|
covered = (self._flat.face_index >= 0)[..., None]
|
||||||
|
if self._background == "gray":
|
||||||
|
grey = image.astype(np.float32) @ np.array([0.299, 0.587, 0.114], np.float32)
|
||||||
|
shade = np.clip(grey * 0.75, 0, 255).astype(np.uint8)
|
||||||
|
data = np.repeat(shade[..., None], 3, axis=2)
|
||||||
|
elif self._background == "dim":
|
||||||
|
data = (image.astype(np.float32) * 0.4).astype(np.uint8)
|
||||||
|
else: # "outline": the patch as a flat grey shape on near-black
|
||||||
|
data = np.where(covered, np.uint8(78), np.uint8(14)) * np.ones(3, np.uint8)
|
||||||
|
data = np.ascontiguousarray(data, dtype=np.uint8)
|
||||||
|
height, width = data.shape[:2]
|
||||||
|
qimage = QImage(data.data, width, height, 3 * width, QImage.Format.Format_RGB888)
|
||||||
|
pixmap = QPixmap.fromImage(qimage.copy())
|
||||||
|
self._bases[self._background] = pixmap
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
def _paint_marks(self, canvas: QPixmap, scale: float) -> None:
|
||||||
|
"""Draw every mark on the *scaled* map, at a size that is legible on screen.
|
||||||
|
|
||||||
|
The marks used to be drawn on the native 1024-pixel image and scaled down
|
||||||
|
with it, so a six-pixel dot was under two pixels wide on a pane a quarter
|
||||||
|
the size, on top of a multi-coloured texture. Drawing after the scale keeps
|
||||||
|
every mark the same on-screen size whatever the pane, and each gets a dark
|
||||||
|
outline so it reads on any colour.
|
||||||
|
"""
|
||||||
|
if not (self._sequence or self._candidates or self._marker or self._selected):
|
||||||
|
return
|
||||||
|
|
||||||
|
def at(x: float, y: float) -> QPointF:
|
||||||
|
return QPointF((x + 0.5) * scale, (y + 0.5) * scale)
|
||||||
|
|
||||||
|
colour = self._marker_colour
|
||||||
painter = QPainter(canvas)
|
painter = QPainter(canvas)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
try:
|
try:
|
||||||
|
if self._show_path and len(self._sequence) > 1:
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 170), 3.5))
|
||||||
|
for first, second in zip(self._sequence, self._sequence[1:]):
|
||||||
|
painter.drawLine(at(first[0], first[1]), at(second[0], second[1]))
|
||||||
|
path = QColor(colour)
|
||||||
|
path.setAlpha(210)
|
||||||
|
painter.setPen(QPen(path, 1.6))
|
||||||
|
for first, second in zip(self._sequence, self._sequence[1:]):
|
||||||
|
painter.drawLine(at(first[0], first[1]), at(second[0], second[1]))
|
||||||
|
for x, y, usable in self._candidates:
|
||||||
|
centre = at(x, y)
|
||||||
|
if usable:
|
||||||
|
painter.setBrush(QBrush())
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 230), 4))
|
||||||
|
painter.drawEllipse(centre, 5.0, 5.0)
|
||||||
|
painter.setPen(QPen(colour, 2))
|
||||||
|
painter.drawEllipse(centre, 5.0, 5.0)
|
||||||
|
else:
|
||||||
|
for width, pen_colour in ((4.5, QColor(0, 0, 0, 230)), (2.0, QColor("#ff453a"))):
|
||||||
|
painter.setPen(QPen(pen_colour, width))
|
||||||
|
painter.drawLine(centre + QPointF(-4, -4), centre + QPointF(4, 4))
|
||||||
|
painter.drawLine(centre + QPointF(-4, 4), centre + QPointF(4, -4))
|
||||||
|
font = painter.font()
|
||||||
|
font.setPixelSize(11)
|
||||||
|
font.setBold(True)
|
||||||
|
painter.setFont(font)
|
||||||
|
for x, y, label, selected in self._sequence:
|
||||||
|
centre = at(x, y)
|
||||||
|
radius = 6.5 if selected else 4.5
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 235), 2.5))
|
||||||
|
painter.setBrush(QColor("#ffffff") if selected else colour)
|
||||||
|
painter.drawEllipse(centre, radius, radius)
|
||||||
|
if selected:
|
||||||
|
painter.setPen(QPen(colour, 2))
|
||||||
|
painter.setBrush(QBrush())
|
||||||
|
painter.drawEllipse(centre, radius + 3.0, radius + 3.0)
|
||||||
|
if label:
|
||||||
|
text = QPainterPath()
|
||||||
|
text.addText(centre.x() + radius + 3, centre.y() - radius, font, label)
|
||||||
|
painter.setBrush(QBrush())
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 230), 3.2))
|
||||||
|
painter.drawPath(text)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#ffffff"))
|
||||||
|
painter.drawPath(text)
|
||||||
if self._marker is not None:
|
if self._marker is not None:
|
||||||
x, y = self._marker
|
centre = at(*self._marker)
|
||||||
painter.setPen(QPen(Qt.GlobalColor.white, 1, Qt.PenStyle.SolidLine))
|
for width, pen_colour in ((4.0, QColor(0, 0, 0, 230)), (1.6, colour)):
|
||||||
painter.drawLine(x - 8, y, x + 8, y)
|
painter.setPen(QPen(pen_colour, width))
|
||||||
painter.drawLine(x, y - 8, x, y + 8)
|
painter.drawLine(centre + QPointF(-9, 0), centre + QPointF(9, 0))
|
||||||
painter.setPen(QPen(Qt.GlobalColor.black, 1, Qt.PenStyle.SolidLine))
|
painter.drawLine(centre + QPointF(0, -9), centre + QPointF(0, 9))
|
||||||
painter.drawEllipse(QPoint(x, y), 5, 5)
|
|
||||||
if self._selected is not None:
|
if self._selected is not None:
|
||||||
x, y = self._selected
|
centre = at(*self._selected)
|
||||||
painter.setPen(QPen(QColor("#ffffff"), 2))
|
painter.setPen(QPen(QColor(0, 0, 0, 235), 4))
|
||||||
painter.setBrush(QColor("#d92d20"))
|
painter.setBrush(QBrush())
|
||||||
painter.drawEllipse(QPoint(x, y), 6, 6)
|
painter.drawEllipse(centre, 7.0, 7.0)
|
||||||
|
painter.setPen(QPen(QColor("#ff453a"), 2.2))
|
||||||
|
painter.setBrush(QColor("#ff453a"))
|
||||||
|
painter.drawEllipse(centre, 5.0, 5.0)
|
||||||
finally:
|
finally:
|
||||||
painter.end()
|
painter.end()
|
||||||
return canvas
|
|
||||||
|
|
||||||
def _refresh(self) -> None:
|
def _refresh(self) -> None:
|
||||||
"""Scale the marked image to fit the pane and show all of it."""
|
"""Scale the map to fit the pane, then draw the marks on the result."""
|
||||||
marked = self._marked()
|
base = self._base()
|
||||||
if marked is None:
|
if base is None or base.width() <= 0 or base.height() <= 0:
|
||||||
return
|
|
||||||
if self._flat is None or marked.width() <= 0 or marked.height() <= 0:
|
|
||||||
return
|
return
|
||||||
available_w = max(1, self.width())
|
available_w = max(1, self.width())
|
||||||
available_h = max(1, self.height())
|
available_h = max(1, self.height())
|
||||||
scale = min(available_w / marked.width(), available_h / marked.height())
|
# Never magnify: a pane larger than the image shows it as it is.
|
||||||
# Never magnify: a 320-pixel pane showing a 1024 image should not blur it
|
scale = min(available_w / base.width(), available_h / base.height(), 1.0)
|
||||||
# up, and a pane larger than the image can centre it as-is.
|
|
||||||
scale = min(scale, 1.0)
|
|
||||||
if scale >= 1.0:
|
if scale >= 1.0:
|
||||||
self.setPixmap(marked)
|
shown = base.copy()
|
||||||
return
|
else:
|
||||||
self.setPixmap(
|
shown = base.scaled(
|
||||||
marked.scaled(
|
max(1, int(base.width() * scale)),
|
||||||
max(1, int(marked.width() * scale)),
|
max(1, int(base.height() * scale)),
|
||||||
max(1, int(marked.height() * scale)),
|
|
||||||
Qt.AspectRatioMode.KeepAspectRatio,
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
Qt.TransformationMode.SmoothTransformation,
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
)
|
)
|
||||||
)
|
# The true factor after rounding to whole pixels, so marks land on
|
||||||
|
# the pixel they name.
|
||||||
|
scale = shown.width() / base.width()
|
||||||
|
self._paint_marks(shown, scale)
|
||||||
|
self.setPixmap(shown)
|
||||||
|
|
||||||
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
@@ -235,6 +374,9 @@ class FingerPanel(QGroupBox):
|
|||||||
#: Whether to ask before moving. Persisted; the window honours it for every
|
#: Whether to ask before moving. Persisted; the window honours it for every
|
||||||
#: pose move, including the rail's return-to-zero.
|
#: pose move, including the rail's return-to-zero.
|
||||||
confirmChanged = Signal(bool)
|
confirmChanged = Signal(bool)
|
||||||
|
#: The 2D map's look changed: ``(background, marker colour name)``. Persisted by
|
||||||
|
#: the window; the choice is the operator's and outlives the session.
|
||||||
|
viewStyleChanged = Signal(str, str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -361,6 +503,37 @@ class FingerPanel(QGroupBox):
|
|||||||
# that crops the UV space instead of shrinking it.
|
# that crops the UV space instead of shrinking it.
|
||||||
column.addWidget(self.view, 1)
|
column.addWidget(self.view, 1)
|
||||||
|
|
||||||
|
# How the map and its marks are drawn. The texture is multi-coloured, so the
|
||||||
|
# background and the dot colour are a pair the operator tunes by eye.
|
||||||
|
style = QHBoxLayout()
|
||||||
|
style.setContentsMargins(0, 0, 0, 0)
|
||||||
|
style.setSpacing(6)
|
||||||
|
self.bg_label = QLabel(box)
|
||||||
|
self.bg_label.setObjectName("hintLabel")
|
||||||
|
self.bg_combo = QComboBox(box)
|
||||||
|
self.bg_combo.setObjectName("compactField")
|
||||||
|
self.colour_label = QLabel(box)
|
||||||
|
self.colour_label.setObjectName("hintLabel")
|
||||||
|
self.colour_combo = QComboBox(box)
|
||||||
|
self.colour_combo.setObjectName("compactField")
|
||||||
|
for combo in (self.bg_combo, self.colour_combo):
|
||||||
|
combo.setFixedHeight(26)
|
||||||
|
combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
for mode in BACKGROUNDS:
|
||||||
|
self.bg_combo.addItem("", mode)
|
||||||
|
for name, hex_value in MARKER_COLOURS:
|
||||||
|
self.colour_combo.addItem("", name)
|
||||||
|
self.colour_combo.setItemData(
|
||||||
|
self.colour_combo.count() - 1, QColor(hex_value), Qt.ItemDataRole.DecorationRole
|
||||||
|
)
|
||||||
|
self.bg_combo.currentIndexChanged.connect(self._on_view_style)
|
||||||
|
self.colour_combo.currentIndexChanged.connect(self._on_view_style)
|
||||||
|
style.addWidget(self.bg_label)
|
||||||
|
style.addWidget(self.bg_combo, 1)
|
||||||
|
style.addWidget(self.colour_label)
|
||||||
|
style.addWidget(self.colour_combo, 1)
|
||||||
|
column.addLayout(style)
|
||||||
|
|
||||||
self.view_label = QLabel(box)
|
self.view_label = QLabel(box)
|
||||||
self.view_label.setObjectName("hintLabel")
|
self.view_label.setObjectName("hintLabel")
|
||||||
self.view_label.setWordWrap(True)
|
self.view_label.setWordWrap(True)
|
||||||
@@ -876,6 +1049,16 @@ class FingerPanel(QGroupBox):
|
|||||||
self._sync_sim_button()
|
self._sync_sim_button()
|
||||||
self.refresh_readouts(point, normal, pose, face)
|
self.refresh_readouts(point, normal, pose, face)
|
||||||
self._refresh_path()
|
self._refresh_path()
|
||||||
|
# The result stays with the views that made it. It used to be pushed into
|
||||||
|
# the window's global status strip, which every page shares, so a
|
||||||
|
# geometry detail ended up beside the connection and stop controls.
|
||||||
|
text = t(
|
||||||
|
"finger.picked",
|
||||||
|
residual=f"{row['residual']:.3f}",
|
||||||
|
yaw=f"{row['yaw']:.2f}",
|
||||||
|
pitch=f"{row['pitch']:.2f}",
|
||||||
|
)
|
||||||
|
self.status_label.setText(text if row["residual"] <= 0.5 else f"⚠ {text}")
|
||||||
return row
|
return row
|
||||||
|
|
||||||
# ----------------------------------------------------------------- readouts
|
# ----------------------------------------------------------------- readouts
|
||||||
@@ -1041,7 +1224,67 @@ class FingerPanel(QGroupBox):
|
|||||||
|
|
||||||
# ------------------------------------------------------------- translations
|
# ------------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def _on_view_style(self, *_args) -> None:
|
||||||
|
mode = str(self.bg_combo.currentData() or BACKGROUNDS[0])
|
||||||
|
name = str(self.colour_combo.currentData() or MARKER_COLOURS[0][0])
|
||||||
|
hex_value = dict(MARKER_COLOURS).get(name, MARKER_COLOURS[0][1])
|
||||||
|
self.view.set_background(mode)
|
||||||
|
self.view.set_marker_colour(hex_value)
|
||||||
|
self.viewStyleChanged.emit(mode, name)
|
||||||
|
|
||||||
|
def set_view_style(self, mode: str, colour: str) -> None:
|
||||||
|
"""Apply a remembered look without announcing it as a change."""
|
||||||
|
for combo, value in ((self.bg_combo, mode), (self.colour_combo, colour)):
|
||||||
|
index = combo.findData(value)
|
||||||
|
if index >= 0:
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.setCurrentIndex(index)
|
||||||
|
combo.blockSignals(False)
|
||||||
|
self._on_view_style_silent()
|
||||||
|
|
||||||
|
def _on_view_style_silent(self) -> None:
|
||||||
|
mode = str(self.bg_combo.currentData() or BACKGROUNDS[0])
|
||||||
|
name = str(self.colour_combo.currentData() or MARKER_COLOURS[0][0])
|
||||||
|
self.view.set_background(mode)
|
||||||
|
self.view.set_marker_colour(dict(MARKER_COLOURS).get(name, MARKER_COLOURS[0][1]))
|
||||||
|
|
||||||
|
def view_style(self) -> tuple[str, str]:
|
||||||
|
return (
|
||||||
|
str(self.bg_combo.currentData() or BACKGROUNDS[0]),
|
||||||
|
str(self.colour_combo.currentData() or MARKER_COLOURS[0][0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def solve_pixel_row(self, x: int, y: int) -> dict[str, float] | None:
|
||||||
|
"""The pose row for a map pixel, with no effect on the picked point or the views.
|
||||||
|
|
||||||
|
The same numbers a click produces (see :meth:`handle_pick`), for callers that
|
||||||
|
are laying out many points at once and must not move the single picked one.
|
||||||
|
"""
|
||||||
|
flat = self.view.flat
|
||||||
|
if flat is None:
|
||||||
|
return None
|
||||||
|
sample = flat.sample(x, y)
|
||||||
|
if sample is None:
|
||||||
|
return None
|
||||||
|
point, normal, _face = sample
|
||||||
|
origin_x, origin_y = uv_origin(flat)
|
||||||
|
pose = self._stack.solve(point, normal)
|
||||||
|
return {
|
||||||
|
"x": float(point[0]), "y": float(point[1]), "z": float(point[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),
|
||||||
|
"u_px": x - origin_x, "v_px": origin_y - y,
|
||||||
|
}
|
||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
|
self.bg_label.setText(t("finger.bg_label"))
|
||||||
|
self.colour_label.setText(t("finger.colour_label"))
|
||||||
|
for index, mode in enumerate(BACKGROUNDS):
|
||||||
|
self.bg_combo.setItemText(index, t(f"finger.bg_{mode}"))
|
||||||
|
for index, (name, _hex) in enumerate(MARKER_COLOURS):
|
||||||
|
self.colour_combo.setItemText(index, t(f"finger.colour_{name}"))
|
||||||
self.setTitle(t("finger.title"))
|
self.setTitle(t("finger.title"))
|
||||||
self.path_label.setText(t("finger.bundle_path"))
|
self.path_label.setText(t("finger.bundle_path"))
|
||||||
self.load_button.setText(t("finger.load"))
|
self.load_button.setText(t("finger.load"))
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QTabWidget,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -53,6 +54,7 @@ from ..sequence_io import (
|
|||||||
format_span,
|
format_span,
|
||||||
summarise,
|
summarise,
|
||||||
)
|
)
|
||||||
|
from .array_tool import ArrayTool
|
||||||
|
|
||||||
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
||||||
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
||||||
@@ -72,7 +74,7 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
#: How many rows the two-column profile form has.
|
#: How many rows the two-column profile form has.
|
||||||
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
|
PROFILE_ROWS = (len(PROFILE_FIELDS) + 2) // 3
|
||||||
|
|
||||||
#: Point-table columns: ``(key, locale_key)``.
|
#: Point-table columns: ``(key, locale_key)``.
|
||||||
#:
|
#:
|
||||||
@@ -84,17 +86,25 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
|||||||
("x", "sequence.col_x"),
|
("x", "sequence.col_x"),
|
||||||
("y", "sequence.col_y"),
|
("y", "sequence.col_y"),
|
||||||
("z", "sequence.col_z"),
|
("z", "sequence.col_z"),
|
||||||
|
("u", "sequence.col_u"),
|
||||||
|
("v", "sequence.col_v"),
|
||||||
("status", "sequence.col_status"),
|
("status", "sequence.col_status"),
|
||||||
("sensitivity", "sequence.col_sensitivity"),
|
("sensitivity", "sequence.col_sensitivity"),
|
||||||
("samples", "sequence.col_samples"),
|
("samples", "sequence.col_samples"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Column index by key, so no code has to count columns.
|
||||||
|
_COL = {key: index for index, (key, _locale_key) in enumerate(POINT_COLUMNS)}
|
||||||
|
|
||||||
|
#: Columns whose text is centred.
|
||||||
|
_CENTRED = ("u", "v", "status", "sensitivity", "samples")
|
||||||
|
|
||||||
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
||||||
#: coordinate is one that nothing has checked against the sensor.
|
#: coordinate is one that nothing has checked against the sensor.
|
||||||
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
||||||
|
|
||||||
#: Column widths of the point table, by column index.
|
#: Column widths of the point table, by column index.
|
||||||
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
|
_POINT_COLUMN_WIDTHS = (50, 110, 110, 110, 100, 100, 110, 160, 100)
|
||||||
|
|
||||||
#: Reserved controls and the phase that will fill each one.
|
#: Reserved controls and the phase that will fill each one.
|
||||||
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
|
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
|
||||||
@@ -151,6 +161,13 @@ class SequenceStep:
|
|||||||
class SequencePanel(QGroupBox):
|
class SequencePanel(QGroupBox):
|
||||||
"""Workflow P5: profile, preview, validation and the point list."""
|
"""Workflow P5: profile, preview, validation and the point list."""
|
||||||
|
|
||||||
|
#: The list changed (rows added, removed or replaced), or the selection did: the
|
||||||
|
#: window redraws the sequence on the 2D map from either.
|
||||||
|
pointsChanged = Signal()
|
||||||
|
#: Add the point picked on the 2D map to the list.
|
||||||
|
addPickedRequested = Signal()
|
||||||
|
#: Send the selected rows' U/V to the capture page's queue: ``[(u, v), ...]``.
|
||||||
|
sendToCaptureRequested = Signal(object)
|
||||||
generateRequested = Signal()
|
generateRequested = Signal()
|
||||||
simulateRequested = Signal()
|
simulateRequested = Signal()
|
||||||
dryRunRequested = Signal()
|
dryRunRequested = Signal()
|
||||||
@@ -202,57 +219,70 @@ class SequencePanel(QGroupBox):
|
|||||||
box.setObjectName("groupBox")
|
box.setObjectName("groupBox")
|
||||||
outer = QVBoxLayout(box)
|
outer = QVBoxLayout(box)
|
||||||
outer.setContentsMargins(10, 8, 10, 8)
|
outer.setContentsMargins(10, 8, 10, 8)
|
||||||
outer.setSpacing(4)
|
outer.setSpacing(6)
|
||||||
|
|
||||||
# The name is what the document is called on disk and in the export, so
|
# The name is what the document is called on disk and in the export, so
|
||||||
# it lives at the top of the block that defines the sequence rather than
|
# it lives at the top of the block that defines the sequence rather than
|
||||||
# in a dialog.
|
# in a dialog. Name and note share one row: two stacked full-width rows
|
||||||
|
# took more height than the whole array tool below them.
|
||||||
name_row = QHBoxLayout()
|
name_row = QHBoxLayout()
|
||||||
name_row.setSpacing(8)
|
name_row.setSpacing(8)
|
||||||
self._name_label = QLabel(box)
|
self._name_label = QLabel(box)
|
||||||
self._name_label.setObjectName("hintLabel")
|
self._name_label.setObjectName("hintLabel")
|
||||||
self.name_edit = QLineEdit(box)
|
self.name_edit = QLineEdit(box)
|
||||||
|
self.name_edit.setObjectName("compactField")
|
||||||
self.name_edit.setFixedHeight(28)
|
self.name_edit.setFixedHeight(28)
|
||||||
name_row.addWidget(self._name_label)
|
|
||||||
name_row.addWidget(self.name_edit, 1)
|
|
||||||
outer.addLayout(name_row)
|
|
||||||
|
|
||||||
self._note_label = QLabel(box)
|
self._note_label = QLabel(box)
|
||||||
self._note_label.setObjectName("hintLabel")
|
self._note_label.setObjectName("hintLabel")
|
||||||
self.note_edit = QLineEdit(box)
|
self.note_edit = QLineEdit(box)
|
||||||
|
self.note_edit.setObjectName("compactField")
|
||||||
self.note_edit.setFixedHeight(28)
|
self.note_edit.setFixedHeight(28)
|
||||||
outer.addWidget(self._note_label)
|
name_row.addWidget(self._name_label)
|
||||||
outer.addWidget(self.note_edit)
|
name_row.addWidget(self.name_edit, 1)
|
||||||
|
name_row.addWidget(self._note_label)
|
||||||
|
name_row.addWidget(self.note_edit, 1)
|
||||||
|
outer.addLayout(name_row)
|
||||||
|
|
||||||
grid = QGridLayout()
|
# Two pages: where the points go (the array tool) and how each is measured
|
||||||
grid.setContentsMargins(0, 0, 0, 0)
|
# (the motion parameters). The parameters are set once and then left alone,
|
||||||
|
# so they sit behind a tab instead of taking half the block every time.
|
||||||
|
self.profile_tabs = QTabWidget(box)
|
||||||
|
self.profile_tabs.setObjectName("innerTabs")
|
||||||
|
self.array_tool = ArrayTool(self.profile_tabs)
|
||||||
|
self.profile_tabs.addTab(self.array_tool, "")
|
||||||
|
params = QWidget(self.profile_tabs)
|
||||||
|
grid = QGridLayout(params)
|
||||||
|
grid.setContentsMargins(4, 8, 4, 4)
|
||||||
grid.setHorizontalSpacing(10)
|
grid.setHorizontalSpacing(10)
|
||||||
grid.setVerticalSpacing(4)
|
grid.setVerticalSpacing(6)
|
||||||
outer.addLayout(grid)
|
|
||||||
|
|
||||||
# Two fields per row: label, field, label, field. Seeing the whole profile
|
# Three label/field pairs per row, so the whole profile is visible at once
|
||||||
# at once is what lets an operator sanity-check a pitch against a depth.
|
# (that is what lets an operator sanity-check a pitch against a depth) in a
|
||||||
|
# third of the height two pairs per row took.
|
||||||
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
|
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
|
||||||
row, side = divmod(index, 2)
|
row, side = divmod(index, 3)
|
||||||
column = side * 2
|
column = side * 2
|
||||||
label = QLabel(box)
|
label = QLabel(params)
|
||||||
label.setObjectName("hintLabel")
|
label.setObjectName("hintLabel")
|
||||||
grid.addWidget(label, row, column)
|
grid.addWidget(label, row, column)
|
||||||
self._profile_labels.append(label)
|
self._profile_labels.append(label)
|
||||||
|
|
||||||
if kind == "choice":
|
if kind == "choice":
|
||||||
field: QWidget = QComboBox(box)
|
field: QWidget = QComboBox(params)
|
||||||
field.setFixedHeight(28)
|
|
||||||
self._choices[key] = field # type: ignore[assignment]
|
self._choices[key] = field # type: ignore[assignment]
|
||||||
else:
|
else:
|
||||||
field = QLineEdit(box)
|
field = QLineEdit(params)
|
||||||
|
field.setObjectName("compactField")
|
||||||
field.setFixedHeight(28)
|
field.setFixedHeight(28)
|
||||||
field.setProperty("fieldKey", key)
|
field.setProperty("fieldKey", key)
|
||||||
grid.addWidget(field, row, column + 1)
|
grid.addWidget(field, row, column + 1)
|
||||||
self._profile_fields[key] = field
|
self._profile_fields[key] = field
|
||||||
|
|
||||||
grid.setColumnStretch(1, 1)
|
for column in (1, 3, 5):
|
||||||
grid.setColumnStretch(3, 1)
|
grid.setColumnStretch(column, 1)
|
||||||
|
grid.setRowStretch(PROFILE_ROWS, 1)
|
||||||
|
self.profile_tabs.addTab(params, "")
|
||||||
|
outer.addWidget(self.profile_tabs, 1)
|
||||||
self._profile_box = box
|
self._profile_box = box
|
||||||
return box
|
return box
|
||||||
|
|
||||||
@@ -392,7 +422,10 @@ class SequencePanel(QGroupBox):
|
|||||||
|
|
||||||
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
|
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
# Rows are selectable: a few of them are sent to the capture page, and a
|
||||||
|
# whole-row highlight says which ones.
|
||||||
|
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
self.table.setAlternatingRowColors(True)
|
self.table.setAlternatingRowColors(True)
|
||||||
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
|
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
|
||||||
@@ -405,20 +438,7 @@ class SequencePanel(QGroupBox):
|
|||||||
self.table.setMinimumHeight(120)
|
self.table.setMinimumHeight(120)
|
||||||
|
|
||||||
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
|
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
|
||||||
values = {
|
self._write_row(row, str(row + 1), x, y, z, None, t("sequence.status_pending"))
|
||||||
0: str(row + 1),
|
|
||||||
1: f"{x:.3f}",
|
|
||||||
2: f"{y:.3f}",
|
|
||||||
3: f"{z:.3f}",
|
|
||||||
4: t("sequence.status_pending"),
|
|
||||||
5: "—",
|
|
||||||
6: "—",
|
|
||||||
}
|
|
||||||
for column_index, text in values.items():
|
|
||||||
item = QTableWidgetItem(text)
|
|
||||||
if column_index >= 4:
|
|
||||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
self.table.setItem(row, column_index, item)
|
|
||||||
|
|
||||||
# The count and span line: the two numbers an operator reasons with, and
|
# The count and span line: the two numbers an operator reasons with, and
|
||||||
# the only place a wrong pitch or margin shows up before a seven-minute
|
# the only place a wrong pitch or margin shows up before a seven-minute
|
||||||
@@ -428,6 +448,27 @@ class SequencePanel(QGroupBox):
|
|||||||
self.summary_label.setWordWrap(True)
|
self.summary_label.setWordWrap(True)
|
||||||
|
|
||||||
column.addWidget(self.table, 1)
|
column.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
# What to do with the rows: take the point picked on the map, drop rows,
|
||||||
|
# and hand the selected ones to the capture page.
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
actions.setContentsMargins(0, 0, 0, 0)
|
||||||
|
actions.setSpacing(8)
|
||||||
|
self.add_picked_button = QPushButton(box)
|
||||||
|
self.add_picked_button.setProperty("rail", True)
|
||||||
|
self.add_picked_button.clicked.connect(self.addPickedRequested)
|
||||||
|
self.remove_button = QPushButton(box)
|
||||||
|
self.remove_button.setProperty("rail", True)
|
||||||
|
self.remove_button.clicked.connect(self.remove_selected)
|
||||||
|
self.send_button = QPushButton(box)
|
||||||
|
self.send_button.setObjectName("primaryButton")
|
||||||
|
self.send_button.clicked.connect(self._on_send)
|
||||||
|
actions.addWidget(self.add_picked_button)
|
||||||
|
actions.addWidget(self.remove_button)
|
||||||
|
actions.addStretch(1)
|
||||||
|
actions.addWidget(self.send_button)
|
||||||
|
column.addLayout(actions)
|
||||||
|
self.table.itemSelectionChanged.connect(self._sync_action_buttons)
|
||||||
column.addWidget(self.summary_label)
|
column.addWidget(self.summary_label)
|
||||||
|
|
||||||
self.point_hint = QLabel(box)
|
self.point_hint = QLabel(box)
|
||||||
@@ -505,26 +546,108 @@ class SequencePanel(QGroupBox):
|
|||||||
def point_row_count(self) -> int:
|
def point_row_count(self) -> int:
|
||||||
return self.table.rowCount()
|
return self.table.rowCount()
|
||||||
|
|
||||||
def append_point(self, x: float, y: float, z: float, status: str = "") -> int:
|
def _write_row(
|
||||||
"""Add a generated point. Used by the generator once P5 exists."""
|
self,
|
||||||
|
row: int,
|
||||||
|
index_text: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
uv: tuple[float, float] | None,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
"""Fill one row by column key, so the layout lives in one place."""
|
||||||
|
values = {
|
||||||
|
"index": index_text,
|
||||||
|
"x": f"{x:.3f}",
|
||||||
|
"y": f"{y:.3f}",
|
||||||
|
"z": f"{z:.3f}",
|
||||||
|
"u": "—" if uv is None else f"{uv[0]:.1f}",
|
||||||
|
"v": "—" if uv is None else f"{uv[1]:.1f}",
|
||||||
|
"status": status,
|
||||||
|
"sensitivity": "—",
|
||||||
|
"samples": "—",
|
||||||
|
}
|
||||||
|
for key, text in values.items():
|
||||||
|
item = QTableWidgetItem(text)
|
||||||
|
if key in _CENTRED:
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.table.setItem(row, _COL[key], item)
|
||||||
|
|
||||||
|
def append_point(
|
||||||
|
self,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
status: str = "",
|
||||||
|
uv: tuple[float, float] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Add a point: from the 2D map's pick, or from the generator once P5 exists."""
|
||||||
row = self.table.rowCount()
|
row = self.table.rowCount()
|
||||||
self.table.insertRow(row)
|
self.table.insertRow(row)
|
||||||
values = {
|
self._write_row(row, str(row + 1), x, y, z, uv, status or t("sequence.status_pending"))
|
||||||
0: str(row + 1),
|
self._dirty = True
|
||||||
1: f"{x:.3f}",
|
self.refresh_summary()
|
||||||
2: f"{y:.3f}",
|
self._sync_action_buttons()
|
||||||
3: f"{z:.3f}",
|
|
||||||
4: status or t("sequence.status_pending"),
|
|
||||||
5: "—",
|
|
||||||
6: "—",
|
|
||||||
}
|
|
||||||
for column, text in values.items():
|
|
||||||
item = QTableWidgetItem(text)
|
|
||||||
if column >= 4:
|
|
||||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
self.table.setItem(row, column, item)
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def remove_selected(self) -> int:
|
||||||
|
"""Remove the selected rows and renumber the rest; returns how many went."""
|
||||||
|
rows = self.selected_rows()
|
||||||
|
for row in reversed(rows):
|
||||||
|
self.table.removeRow(row)
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
item = self.table.item(row, _COL["index"])
|
||||||
|
if item is not None:
|
||||||
|
item.setText(str(row + 1))
|
||||||
|
if rows:
|
||||||
|
self._dirty = True
|
||||||
|
self.refresh_summary()
|
||||||
|
self._sync_action_buttons()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def selected_rows(self) -> list[int]:
|
||||||
|
return sorted({index.row() for index in self.table.selectionModel().selectedRows()})
|
||||||
|
|
||||||
|
def uv_of_row(self, row: int) -> tuple[float, float] | None:
|
||||||
|
"""The row's U/V, or ``None`` when it has none (a placeholder or imported row)."""
|
||||||
|
cells = self.point_row(row)
|
||||||
|
try:
|
||||||
|
return float(cells["u"]), float(cells["v"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def selected_uv(self) -> list[tuple[float, float]]:
|
||||||
|
"""U/V of the selected rows that have one, in list order."""
|
||||||
|
found = (self.uv_of_row(row) for row in self.selected_rows())
|
||||||
|
return [uv for uv in found if uv is not None]
|
||||||
|
|
||||||
|
def _on_send(self) -> None:
|
||||||
|
uvs = self.selected_uv()
|
||||||
|
if uvs:
|
||||||
|
self.sendToCaptureRequested.emit(uvs)
|
||||||
|
|
||||||
|
def map_points(self) -> list[tuple[int, float, float, bool]]:
|
||||||
|
"""``(row number, u, v, selected)`` for every row with a U/V, in list order."""
|
||||||
|
selected = set(self.selected_rows())
|
||||||
|
found = []
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
uv = self.uv_of_row(row)
|
||||||
|
if uv is not None:
|
||||||
|
found.append((row + 1, uv[0], uv[1], row in selected))
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _sync_action_buttons(self) -> None:
|
||||||
|
rows = self.selected_rows()
|
||||||
|
self.pointsChanged.emit()
|
||||||
|
self.remove_button.setEnabled(bool(rows))
|
||||||
|
with_uv = len(self.selected_uv())
|
||||||
|
self.send_button.setEnabled(with_uv > 0)
|
||||||
|
self.send_button.setText(t("sequence.btn_send", count=with_uv))
|
||||||
|
self.send_button.setToolTip(
|
||||||
|
t("sequence.send_tip") if with_uv else t("sequence.send_none")
|
||||||
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------- points & state
|
# ----------------------------------------------------------- points & state
|
||||||
|
|
||||||
def points(self) -> list[SequencePoint]:
|
def points(self) -> list[SequencePoint]:
|
||||||
@@ -536,8 +659,14 @@ class SequencePanel(QGroupBox):
|
|||||||
target = {axis: float(cells[axis]) for axis in "xyz"}
|
target = {axis: float(cells[axis]) for axis in "xyz"}
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue # a placeholder row that was never generated
|
continue # a placeholder row that was never generated
|
||||||
|
uv = self.uv_of_row(row)
|
||||||
points.append(
|
points.append(
|
||||||
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
|
SequencePoint(
|
||||||
|
id=row + 1,
|
||||||
|
target_mm=target,
|
||||||
|
uv_px=None if uv is None else {"u": uv[0], "v": uv[1]},
|
||||||
|
enabled=cells["status"] != t("sequence.status_skipped"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return points
|
return points
|
||||||
|
|
||||||
@@ -547,22 +676,23 @@ class SequencePanel(QGroupBox):
|
|||||||
for point in points:
|
for point in points:
|
||||||
row = self.table.rowCount()
|
row = self.table.rowCount()
|
||||||
self.table.insertRow(row)
|
self.table.insertRow(row)
|
||||||
values = {
|
uv = (
|
||||||
0: str(point.id),
|
None
|
||||||
1: f"{point.target_mm.get('x', 0.0):.3f}",
|
if point.uv_px is None
|
||||||
2: f"{point.target_mm.get('y', 0.0):.3f}",
|
else (float(point.uv_px.get("u", 0.0)), float(point.uv_px.get("v", 0.0)))
|
||||||
3: f"{point.target_mm.get('z', 0.0):.3f}",
|
)
|
||||||
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
|
self._write_row(
|
||||||
5: "—",
|
row,
|
||||||
6: "—",
|
str(point.id),
|
||||||
}
|
point.target_mm.get("x", 0.0),
|
||||||
for column, text in values.items():
|
point.target_mm.get("y", 0.0),
|
||||||
item = QTableWidgetItem(text)
|
point.target_mm.get("z", 0.0),
|
||||||
if column >= 4:
|
uv,
|
||||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
|
||||||
self.table.setItem(row, column, item)
|
)
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self.refresh_summary()
|
self.refresh_summary()
|
||||||
|
self._sync_action_buttons()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dirty(self) -> bool:
|
def dirty(self) -> bool:
|
||||||
@@ -619,6 +749,9 @@ class SequencePanel(QGroupBox):
|
|||||||
for key, combo in self._choices.items():
|
for key, combo in self._choices.items():
|
||||||
_fill_choice(combo, key)
|
_fill_choice(combo, key)
|
||||||
|
|
||||||
|
self.profile_tabs.setTabText(0, t("sequence.tab_array"))
|
||||||
|
self.profile_tabs.setTabText(1, t("sequence.tab_motion"))
|
||||||
|
self.array_tool.retranslate()
|
||||||
self._name_label.setText(t("sequence.field_name"))
|
self._name_label.setText(t("sequence.field_name"))
|
||||||
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
|
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
|
||||||
self._note_label.setText(t("sequence.field_note"))
|
self._note_label.setText(t("sequence.field_note"))
|
||||||
@@ -639,10 +772,14 @@ class SequencePanel(QGroupBox):
|
|||||||
|
|
||||||
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
||||||
for row in range(self.table.rowCount()):
|
for row in range(self.table.rowCount()):
|
||||||
item = self.table.item(row, 4)
|
item = self.table.item(row, _COL["status"])
|
||||||
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
||||||
item.setText(t("sequence.status_pending"))
|
item.setText(t("sequence.status_pending"))
|
||||||
self.point_hint.setText(t("sequence.points_hint"))
|
self.point_hint.setText(t("sequence.points_hint"))
|
||||||
|
self.add_picked_button.setText(t("sequence.btn_add_picked"))
|
||||||
|
self.add_picked_button.setToolTip(t("sequence.add_picked_tip"))
|
||||||
|
self.remove_button.setText(t("sequence.btn_remove"))
|
||||||
|
self._sync_action_buttons()
|
||||||
self.refresh_summary()
|
self.refresh_summary()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ from ..sensor import (
|
|||||||
rasterise,
|
rasterise,
|
||||||
texture_path_for,
|
texture_path_for,
|
||||||
)
|
)
|
||||||
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE, uv_origin
|
||||||
from ..sequence_io import (
|
from ..sequence_io import (
|
||||||
FILE_SUFFIX,
|
FILE_SUFFIX,
|
||||||
SequenceDocument,
|
SequenceDocument,
|
||||||
@@ -402,6 +402,20 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||||
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
|
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
|
||||||
self.sequence_panel.importRequested.connect(self._on_import_sequence)
|
self.sequence_panel.importRequested.connect(self._on_import_sequence)
|
||||||
|
# The array tool proposes points, the map shows them, the table keeps them.
|
||||||
|
self.sequence_panel.pointsChanged.connect(self._refresh_sequence_overlay)
|
||||||
|
tool = self.sequence_panel.array_tool
|
||||||
|
tool.previewChanged.connect(self._on_array_preview)
|
||||||
|
tool.addRequested.connect(self._on_array_add)
|
||||||
|
tool.centreFromPickRequested.connect(self._on_array_centre_from_pick)
|
||||||
|
self.finger_panel.viewStyleChanged.connect(self._on_view_style_changed)
|
||||||
|
self.finger_panel.set_view_style(
|
||||||
|
str(self.prefs.get("finger_view_background") or "gray"),
|
||||||
|
str(self.prefs.get("finger_marker_colour") or "yellow"),
|
||||||
|
)
|
||||||
|
self._refresh_array_preview()
|
||||||
|
self.sequence_panel.addPickedRequested.connect(self._on_add_picked_to_sequence)
|
||||||
|
self.sequence_panel.sendToCaptureRequested.connect(self._on_send_sequence_to_capture)
|
||||||
self.finger_panel.loadRequested.connect(self._on_load_bundle)
|
self.finger_panel.loadRequested.connect(self._on_load_bundle)
|
||||||
self.finger_panel.pickRequested.connect(self._on_pick_point)
|
self.finger_panel.pickRequested.connect(self._on_pick_point)
|
||||||
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
|
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
|
||||||
@@ -519,6 +533,9 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.finger_panel.set_bundle(bundle, flat)
|
self.finger_panel.set_bundle(bundle, flat)
|
||||||
self.calibration_panel.set_flat(flat, str(path))
|
self.calibration_panel.set_flat(flat, str(path))
|
||||||
self.finger_panel.set_texture_state(textured=flat.textured, path=str(texture_path or ""))
|
self.finger_panel.set_texture_state(textured=flat.textured, path=str(texture_path or ""))
|
||||||
|
# A new surface changes which candidates are usable and where they sit.
|
||||||
|
self._refresh_array_preview()
|
||||||
|
self._refresh_sequence_overlay()
|
||||||
self.prefs["finger_bundle_path"] = str(path)
|
self.prefs["finger_bundle_path"] = str(path)
|
||||||
info = bundle.describe()
|
info = bundle.describe()
|
||||||
self._report_sequence(
|
self._report_sequence(
|
||||||
@@ -591,16 +608,124 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# A UV gap or no bundle: the panel says which; do not imply success.
|
# A UV gap or no bundle: the panel says which; do not imply success.
|
||||||
self.statusBar().showMessage(self.finger_panel.status_label.text())
|
self.statusBar().showMessage(self.finger_panel.status_label.text())
|
||||||
return
|
return
|
||||||
self._report_sequence(
|
# Success is shown by the picker's own status line (see handle_pick); the
|
||||||
t(
|
# strip at the top is for the platform and for actions, not for a pick.
|
||||||
"finger.picked",
|
|
||||||
residual=f"{row['residual']:.3f}",
|
# ----------------------------------------------------------- array and map
|
||||||
yaw=f"{row['yaw']:.2f}",
|
|
||||||
pitch=f"{row['pitch']:.2f}",
|
def _map_origin(self) -> tuple[int, int] | None:
|
||||||
),
|
flat = self.finger_panel.view.flat
|
||||||
ok=row["residual"] <= 0.5,
|
if flat is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uv_origin(flat)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _refresh_sequence_overlay(self) -> None:
|
||||||
|
"""Draw the sequence's points, in order, on the 2D map."""
|
||||||
|
view = self.finger_panel.view
|
||||||
|
origin = self._map_origin()
|
||||||
|
if origin is None:
|
||||||
|
view.set_sequence_points([])
|
||||||
|
return
|
||||||
|
view.set_sequence_points(
|
||||||
|
[
|
||||||
|
(origin[0] + int(round(u)), origin[1] - int(round(v)), str(number), selected)
|
||||||
|
for number, u, v, selected in self.sequence_panel.map_points()
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _refresh_array_preview(self) -> None:
|
||||||
|
tool = self.sequence_panel.array_tool
|
||||||
|
try:
|
||||||
|
points = tool.candidates()
|
||||||
|
except ValueError:
|
||||||
|
points = []
|
||||||
|
self._on_array_preview(points, tool.margin_px)
|
||||||
|
|
||||||
|
def _on_array_preview(self, points: object, margin: int) -> None:
|
||||||
|
"""Check the tool's candidates against the surface and draw the verdict."""
|
||||||
|
from ..array_plan import check_candidates
|
||||||
|
|
||||||
|
tool = self.sequence_panel.array_tool
|
||||||
|
view = self.finger_panel.view
|
||||||
|
try:
|
||||||
|
checked = check_candidates(view.flat, list(points), margin) # type: ignore[call-overload]
|
||||||
|
except ValueError:
|
||||||
|
checked = []
|
||||||
|
tool.set_checked(checked)
|
||||||
|
origin = self._map_origin()
|
||||||
|
if origin is None or not tool.previewing():
|
||||||
|
view.set_candidates([])
|
||||||
|
return
|
||||||
|
view.set_candidates(
|
||||||
|
[(origin[0] + c.u, origin[1] - c.v, c.valid) for c in checked]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_array_add(self, candidates: object) -> None:
|
||||||
|
"""Add the usable candidates, each solved on the surface, skipping repeats."""
|
||||||
|
origin = self._map_origin()
|
||||||
|
if origin is None:
|
||||||
|
return
|
||||||
|
added = repeated = failed = 0
|
||||||
|
for candidate in list(candidates): # type: ignore[call-overload]
|
||||||
|
uv = (float(candidate.u), float(candidate.v))
|
||||||
|
if any(
|
||||||
|
existing is not None
|
||||||
|
and abs(existing[0] - uv[0]) < 0.5
|
||||||
|
and abs(existing[1] - uv[1]) < 0.5
|
||||||
|
for existing in (
|
||||||
|
self.sequence_panel.uv_of_row(i)
|
||||||
|
for i in range(self.sequence_panel.point_row_count())
|
||||||
|
)
|
||||||
|
):
|
||||||
|
repeated += 1
|
||||||
|
continue
|
||||||
|
row = self.finger_panel.solve_pixel_row(origin[0] + candidate.u, origin[1] - candidate.v)
|
||||||
|
if row is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
self.sequence_panel.append_point(
|
||||||
|
row["X"], row["Y"], row["Z"], uv=(row["u_px"], row["v_px"])
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("array.added", added=added, repeated=repeated, failed=failed)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_array_centre_from_pick(self) -> None:
|
||||||
|
picked = self.finger_panel.points()
|
||||||
|
if not picked:
|
||||||
|
self.statusBar().showMessage(t("sequence.add_picked_none"))
|
||||||
|
return
|
||||||
|
self.sequence_panel.array_tool.set_centre(picked[0]["u_px"], picked[0]["v_px"])
|
||||||
|
|
||||||
|
def _on_view_style_changed(self, background: str, colour: str) -> None:
|
||||||
|
self.prefs["finger_view_background"] = background
|
||||||
|
self.prefs["finger_marker_colour"] = colour
|
||||||
|
|
||||||
|
def _on_add_picked_to_sequence(self) -> None:
|
||||||
|
"""Append the point picked on the 2D map to the sequence, with its U/V."""
|
||||||
|
picked = self.finger_panel.points()
|
||||||
|
if not picked:
|
||||||
|
self.statusBar().showMessage(t("sequence.add_picked_none"))
|
||||||
|
return
|
||||||
|
row = picked[0]
|
||||||
|
uv = (float(row["u_px"]), float(row["v_px"]))
|
||||||
|
for index in range(self.sequence_panel.point_row_count()):
|
||||||
|
existing = self.sequence_panel.uv_of_row(index)
|
||||||
|
if existing and abs(existing[0] - uv[0]) < 0.5 and abs(existing[1] - uv[1]) < 0.5:
|
||||||
|
self.statusBar().showMessage(t("sequence.add_picked_duplicate", row=index + 1))
|
||||||
|
return
|
||||||
|
self.sequence_panel.append_point(row["X"], row["Y"], row["Z"], uv=uv)
|
||||||
|
|
||||||
|
def _on_send_sequence_to_capture(self, uvs: object) -> None:
|
||||||
|
"""Queue the selected sequence points on the capture page, and show it."""
|
||||||
|
added, _refused = self.calibration_panel.add_uv_points(list(uvs)) # type: ignore[arg-type]
|
||||||
|
if added:
|
||||||
|
self.workspace.set_current_key("sensor")
|
||||||
|
|
||||||
def _on_finger_stack_changed(self) -> None:
|
def _on_finger_stack_changed(self) -> None:
|
||||||
"""The mounting pose, the tool position or the limits moved.
|
"""The mounting pose, the tool position or the limits moved.
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ from .sequence_panel import SequencePanel
|
|||||||
from .record_overview_dialog import RecordOverviewPage
|
from .record_overview_dialog import RecordOverviewPage
|
||||||
|
|
||||||
#: Tab identity -> locale key for its label. Order is the display order, and it
|
#: Tab identity -> locale key for its label. Order is the display order, and it
|
||||||
#: follows the work: commission the axes, describe the sequence, watch the
|
#: follows the work: commission the axes, set up the geometry and mounting, design
|
||||||
#: sensor, capture data, then review calibration records.
|
#: the sequence against it, watch the sensor, capture data, then review records.
|
||||||
TABS: tuple[tuple[str, str], ...] = (
|
TABS: tuple[tuple[str, str], ...] = (
|
||||||
("axes", "workspace.tab_axes"),
|
("axes", "workspace.tab_axes"),
|
||||||
("sequence", "workspace.tab_sequence"),
|
|
||||||
("finger", "workspace.tab_finger"),
|
("finger", "workspace.tab_finger"),
|
||||||
|
("sequence", "workspace.tab_sequence"),
|
||||||
("sensor", "workspace.tab_sensor"),
|
("sensor", "workspace.tab_sensor"),
|
||||||
("records", "workspace.tab_records"),
|
("records", "workspace.tab_records"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Candidate arrays on the flat map: the grid, the presets and the on-surface check."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from orisys_autocal import array_plan
|
||||||
|
from orisys_autocal.array_plan import (
|
||||||
|
NEAR_EDGE,
|
||||||
|
OFF_SURFACE,
|
||||||
|
PRESETS,
|
||||||
|
check_candidates,
|
||||||
|
grid_uv,
|
||||||
|
preset_uv,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _flat(width: int = 200, height: int = 200, hole: tuple[int, int, int, int] | None = None):
|
||||||
|
"""A fully covered raster, optionally with a rectangular hole (x0, y0, x1, y1)."""
|
||||||
|
covered = np.ones((height, width), dtype=np.int32)
|
||||||
|
if hole:
|
||||||
|
x0, y0, x1, y1 = hole
|
||||||
|
covered[y0:y1, x0:x1] = -1
|
||||||
|
return SimpleNamespace(face_index=covered, width=width, height=height)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_grid_is_centred_and_bottom_row_first():
|
||||||
|
points = grid_uv(2, 3, 10, 20, centre=(0, 100), serpentine=False)
|
||||||
|
assert points == [(-10, 90), (0, 90), (10, 90), (-10, 110), (0, 110), (10, 110)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_serpentine_walks_every_second_row_backwards():
|
||||||
|
points = grid_uv(2, 3, 10, 20, centre=(0, 100), serpentine=True)
|
||||||
|
assert points[3:] == [(10, 110), (0, 110), (-10, 110)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_or_oversized_grid():
|
||||||
|
assert grid_uv(0, 3, 10, 10) == []
|
||||||
|
with pytest.raises(ValueError, match="more than"):
|
||||||
|
grid_uv(30, 30, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_presets_overlap_only_at_the_hardware_check_point():
|
||||||
|
labels = [label for key in PRESETS for label, _u, _v in PRESETS[key]]
|
||||||
|
assert sorted(label for label in set(labels) if labels.count(label) > 1) == ["C13"]
|
||||||
|
# The first-model grid is centred on the hardware-check point.
|
||||||
|
assert ("C13", 0, 470) in PRESETS["stage1"]
|
||||||
|
assert ("C13", 0, 470) in PRESETS["stage0"]
|
||||||
|
assert preset_uv("stage0") == [("C13", 0, 470)]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
preset_uv("nope")
|
||||||
|
assert array_plan.PRESET_KEYS[0] == "stage0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_point_on_the_surface_is_valid_and_off_it_is_not():
|
||||||
|
flat = _flat(hole=(40, 40, 60, 60))
|
||||||
|
origin = (100, 150) # raster (x, y) = (100 + u, 150 - v)
|
||||||
|
result = check_candidates(flat, [(0, 100), (-50, 100)], origin=origin)
|
||||||
|
assert [c.valid for c in result] == [True, False]
|
||||||
|
assert result[1].reason == OFF_SURFACE # raster (50, 50) is in the hole
|
||||||
|
|
||||||
|
|
||||||
|
def test_outside_the_raster_is_off_surface_not_an_error():
|
||||||
|
result = check_candidates(_flat(), [(900, 900)], origin=(100, 150))
|
||||||
|
assert result[0].valid is False and result[0].reason == OFF_SURFACE
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_margin_rejects_points_too_close_to_a_hole_or_the_border():
|
||||||
|
flat = _flat(hole=(100, 60, 110, 70))
|
||||||
|
origin = (100, 150)
|
||||||
|
# raster (100, 75): five pixels below the hole's bottom edge.
|
||||||
|
near = check_candidates(flat, [(0, 75)], margin_px=8, origin=origin)[0]
|
||||||
|
far = check_candidates(flat, [(0, 75)], margin_px=3, origin=origin)[0]
|
||||||
|
assert (near.valid, near.reason) == (False, NEAR_EDGE)
|
||||||
|
assert far.valid
|
||||||
|
# The raster border counts as an edge.
|
||||||
|
assert check_candidates(flat, [(-98, 100)], margin_px=5, origin=origin)[0].reason == NEAR_EDGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_survive_the_check_and_no_surface_vouches_for_nothing():
|
||||||
|
flat = _flat()
|
||||||
|
kept = check_candidates(flat, [("C13", 0, 100)], origin=(100, 150))[0]
|
||||||
|
assert kept.label == "C13" and kept.valid
|
||||||
|
none = check_candidates(None, [("C13", 0, 100), (1, 2)])
|
||||||
|
assert [c.valid for c in none] == [False, False]
|
||||||
|
assert none[0].label == "C13" and none[1].label == ""
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""The array tool and the 2D map's look: what the operator sees and what the tool offers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_autocal.array_plan import Candidate
|
||||||
|
from orisys_autocal.ui.sensor_panel import FlatSurfaceView
|
||||||
|
from PySide6.QtGui import QColor
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_flat(size: int = 64):
|
||||||
|
from orisys_autocal.sensor.uv_flat import FlatSurface
|
||||||
|
|
||||||
|
image = np.zeros((size, size, 3), dtype=np.uint8)
|
||||||
|
image[..., 2] = 200 # a plain blue map: any yellow on it is a mark
|
||||||
|
return FlatSurface(
|
||||||
|
size_hw=(size, size),
|
||||||
|
image_rgb=image,
|
||||||
|
positions_mm=np.zeros((size, size, 3)),
|
||||||
|
normals=np.tile(np.array([0.0, 0.0, 1.0]), (size, size, 1)),
|
||||||
|
face_index=np.zeros((size, size), dtype=np.int32),
|
||||||
|
uv_min=(0.0, 0.0),
|
||||||
|
uv_max=(1.0, 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_stay_visible_when_the_map_is_scaled_down(qt_app):
|
||||||
|
view = FlatSurfaceView()
|
||||||
|
view.resize(32, 32) # half the map's size
|
||||||
|
view.set_flat(_synthetic_flat(64))
|
||||||
|
view.set_marker_colour("#ffd60a")
|
||||||
|
view.set_sequence_points([(32, 32, "", False)])
|
||||||
|
shown = view.pixmap().toImage()
|
||||||
|
centre = QColor(shown.pixel(shown.width() // 2, shown.height() // 2))
|
||||||
|
# The mark is drawn after the scale, so its colour is at the centre of the
|
||||||
|
# shown image instead of being smeared into the blue by the downscale.
|
||||||
|
assert centre.red() > 220 and centre.green() > 180 and centre.blue() < 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_background_can_be_changed_and_marks_survive_it(qt_app):
|
||||||
|
view = FlatSurfaceView()
|
||||||
|
view.resize(64, 64)
|
||||||
|
view.set_flat(_synthetic_flat(64))
|
||||||
|
before = QColor(view.pixmap().toImage().pixel(2, 2))
|
||||||
|
for mode in ("gray", "dim", "outline"):
|
||||||
|
view.set_background(mode)
|
||||||
|
after = QColor(view.pixmap().toImage().pixel(2, 2))
|
||||||
|
assert after != before, mode
|
||||||
|
assert view.background == mode
|
||||||
|
view.set_background("nonsense")
|
||||||
|
assert view.background == "original"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unusable_candidates_are_drawn_apart_from_usable_ones(qt_app):
|
||||||
|
view = FlatSurfaceView()
|
||||||
|
view.resize(64, 64)
|
||||||
|
view.set_flat(_synthetic_flat(64))
|
||||||
|
view.set_marker_colour("#ffd60a")
|
||||||
|
view.set_candidates([(32, 32, False)])
|
||||||
|
shown = view.pixmap().toImage()
|
||||||
|
red_pixels = sum(
|
||||||
|
1
|
||||||
|
for x in range(24, 41)
|
||||||
|
for y in range(24, 41)
|
||||||
|
if QColor(shown.pixel(x, y)).red() > 200 and QColor(shown.pixel(x, y)).green() < 110
|
||||||
|
)
|
||||||
|
assert red_pixels > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_look_is_chosen_on_the_page_and_reported(make_window):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.finger_panel
|
||||||
|
seen: list[tuple[str, str]] = []
|
||||||
|
panel.viewStyleChanged.connect(lambda mode, colour: seen.append((mode, colour)))
|
||||||
|
panel.bg_combo.setCurrentIndex(panel.bg_combo.findData("dim"))
|
||||||
|
panel.colour_combo.setCurrentIndex(panel.colour_combo.findData("white"))
|
||||||
|
assert panel.view_style() == ("dim", "white")
|
||||||
|
assert seen[-1] == ("dim", "white")
|
||||||
|
assert window.prefs["finger_view_background"] == "dim"
|
||||||
|
assert panel.view.marker_colour == QColor("#ffffff")
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_grid_has_rows_times_columns_candidates_and_follows_its_fields(make_window):
|
||||||
|
window = make_window()
|
||||||
|
tool = window.sequence_panel.array_tool
|
||||||
|
assert len(tool.candidates()) == 9
|
||||||
|
tool.spin("rows").setValue(2)
|
||||||
|
tool.spin("cols").setValue(5)
|
||||||
|
assert len(tool.candidates()) == 10
|
||||||
|
tool.mode_combo.setCurrentIndex(tool.mode_combo.findData("preset"))
|
||||||
|
assert tool.mode == "preset"
|
||||||
|
tool.preset_combo.setCurrentIndex(tool.preset_combo.findData("stage0"))
|
||||||
|
assert tool.candidates() == [("C13", 0, 470)]
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_usable_candidates_can_be_added(make_window):
|
||||||
|
window = make_window()
|
||||||
|
tool = window.sequence_panel.array_tool
|
||||||
|
added: list = []
|
||||||
|
tool.addRequested.connect(added.append)
|
||||||
|
tool.set_checked([Candidate(0, 0, False, "off_surface")])
|
||||||
|
assert not tool.add_button.isEnabled()
|
||||||
|
tool.set_checked([Candidate(0, 0, True), Candidate(5, 5, False, "near_edge")])
|
||||||
|
assert tool.add_button.isEnabled()
|
||||||
|
assert tool.add_button.text() == t("array.btn_add", count=1)
|
||||||
|
tool.add_button.click()
|
||||||
|
assert [c.u for c in added[0]] == [0]
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_candidates_skips_a_point_already_in_the_sequence(make_window, monkeypatch):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.sequence_panel
|
||||||
|
monkeypatch.setattr(window, "_map_origin", lambda: (100, 150))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
window.finger_panel,
|
||||||
|
"solve_pixel_row",
|
||||||
|
lambda x, y: {"X": 1.0, "Y": 2.0, "Z": 3.0, "u_px": x - 100.0, "v_px": 150.0 - y},
|
||||||
|
)
|
||||||
|
before = panel.point_row_count()
|
||||||
|
window._on_array_add([Candidate(10, 20), Candidate(30, 40)]) # noqa: SLF001
|
||||||
|
window._on_array_add([Candidate(10, 20), Candidate(50, 60)]) # noqa: SLF001
|
||||||
|
assert panel.point_row_count() == before + 3
|
||||||
|
window.close()
|
||||||
@@ -188,7 +188,7 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi
|
|||||||
root, _, _ = overview_session
|
root, _, _ = overview_session
|
||||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root))
|
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root))
|
||||||
window = make_window()
|
window = make_window()
|
||||||
assert window.workspace.page_keys() == ["axes", "sequence", "finger", "sensor", "records"]
|
assert window.workspace.page_keys() == ["axes", "finger", "sequence", "sensor", "records"]
|
||||||
window.calibration_panel.review_button.click()
|
window.calibration_panel.review_button.click()
|
||||||
assert window.workspace.current_key() == "records"
|
assert window.workspace.current_key() == "records"
|
||||||
page = window.workspace.page("records")
|
page = window.workspace.page("records")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Sequence rows with U/V, the buttons that act on them, and the tab order."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_geometry_tab_comes_before_the_sequence_tab(make_window):
|
||||||
|
window = make_window()
|
||||||
|
assert window.workspace.page_keys() == ["axes", "finger", "sequence", "sensor", "records"]
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_point_table_shows_u_and_v(make_window):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.sequence_panel
|
||||||
|
keys = panel.column_keys()
|
||||||
|
assert keys.index("u") == keys.index("z") + 1 and keys.index("v") == keys.index("u") + 1
|
||||||
|
row = panel.append_point(1.0, 2.0, 3.0, uv=(-130.0, 470.0))
|
||||||
|
cells = panel.point_row(row)
|
||||||
|
assert (cells["u"], cells["v"]) == ("-130.0", "470.0")
|
||||||
|
# The placeholder rows have none, and say so.
|
||||||
|
assert panel.point_row(0)["u"] == "—"
|
||||||
|
assert panel.uv_of_row(0) is None
|
||||||
|
assert panel.uv_of_row(row) == (-130.0, 470.0)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_is_off_until_a_row_with_uv_is_selected(make_window):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.sequence_panel
|
||||||
|
row = panel.append_point(1.0, 2.0, 3.0, uv=(10.0, 20.0))
|
||||||
|
assert not panel.send_button.isEnabled()
|
||||||
|
panel.table.selectRow(0) # a placeholder without U/V
|
||||||
|
assert not panel.send_button.isEnabled()
|
||||||
|
panel.table.selectRow(row)
|
||||||
|
assert panel.send_button.isEnabled()
|
||||||
|
assert panel.send_button.text() == t("sequence.btn_send", count=1)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sending_emits_the_selected_uv_and_removing_renumbers(make_window):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.sequence_panel
|
||||||
|
first = panel.append_point(1.0, 0.0, 0.0, uv=(10.0, 20.0))
|
||||||
|
second = panel.append_point(2.0, 0.0, 0.0, uv=(30.0, 40.0))
|
||||||
|
sent: list = []
|
||||||
|
panel.sendToCaptureRequested.connect(sent.append)
|
||||||
|
panel.table.selectRow(second)
|
||||||
|
panel.send_button.click()
|
||||||
|
assert sent == [[(30.0, 40.0)]]
|
||||||
|
|
||||||
|
panel.table.selectRow(first)
|
||||||
|
assert panel.remove_selected() == 1
|
||||||
|
assert panel.point_row(panel.point_row_count() - 1)["index"] == str(panel.point_row_count())
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_picked_point_is_added_once_with_its_uv(make_window):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.sequence_panel
|
||||||
|
window.finger_panel._point = { # noqa: SLF001 - stands in for a click on a real surface
|
||||||
|
"X": 1.5, "Y": 2.5, "Z": 12.4, "u_px": -130.0, "v_px": 470.0,
|
||||||
|
}
|
||||||
|
before = panel.point_row_count()
|
||||||
|
window._on_add_picked_to_sequence() # noqa: SLF001
|
||||||
|
window._on_add_picked_to_sequence() # noqa: SLF001 - the same point again
|
||||||
|
assert panel.point_row_count() == before + 1
|
||||||
|
assert panel.uv_of_row(before) == (-130.0, 470.0)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sending_to_capture_queues_the_points_and_opens_that_page(make_window, monkeypatch):
|
||||||
|
window = make_window()
|
||||||
|
calls: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
window.calibration_panel, "add_uv_points", lambda uvs: calls.append(uvs) or (len(uvs), 0)
|
||||||
|
)
|
||||||
|
window.workspace.set_current_key("sequence")
|
||||||
|
window._on_send_sequence_to_capture([(1.0, 2.0)]) # noqa: SLF001
|
||||||
|
assert calls == [[(1.0, 2.0)]]
|
||||||
|
assert window.workspace.current_key() == "sensor"
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pick_reports_in_the_picker_not_in_the_global_strip(make_window):
|
||||||
|
window = make_window()
|
||||||
|
strip_before = window.status_strip.last_action_text() if hasattr(
|
||||||
|
window.status_strip, "last_action_text"
|
||||||
|
) else None
|
||||||
|
window._on_pick_point(1, 1) # noqa: SLF001 - no bundle: the picker says why
|
||||||
|
assert window.finger_panel.status_label.text() == t("finger.no_bundle")
|
||||||
|
if strip_before is not None:
|
||||||
|
assert window.status_strip.last_action_text() == strip_before
|
||||||
|
window.close()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""U/V on sequence points: the optional field and the way it travels in a document."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from orisys_autocal.sequence_io import SequencePoint, from_document, to_document
|
||||||
|
|
||||||
|
PROFILE = {"mode": "plane", "pitch_mm": 2.0, "repeats": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def _round_trip(points):
|
||||||
|
document = to_document(profile=PROFILE, points=points)
|
||||||
|
# Through JSON text, as a file would carry it.
|
||||||
|
return from_document(json.loads(json.dumps(document)), current_profile=PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uv_survives_a_round_trip():
|
||||||
|
point = SequencePoint(
|
||||||
|
id=1, target_mm={"x": 1.0, "y": 2.0, "z": 3.0}, uv_px={"u": -130.0, "v": 470.5}
|
||||||
|
)
|
||||||
|
back = _round_trip([point]).points[0]
|
||||||
|
assert back.uv_px == {"u": -130.0, "v": 470.5}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_point_without_uv_stays_without_it():
|
||||||
|
point = SequencePoint(id=1, target_mm={"x": 1.0, "y": 2.0, "z": 3.0})
|
||||||
|
document = to_document(profile=PROFILE, points=[point])
|
||||||
|
assert "uv_px" not in document["points"][0]
|
||||||
|
assert _round_trip([point]).points[0].uv_px is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_older_file_with_no_uv_still_loads():
|
||||||
|
document = to_document(
|
||||||
|
profile=PROFILE,
|
||||||
|
points=[SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4})],
|
||||||
|
)
|
||||||
|
assert from_document(document, current_profile=PROFILE).points[0].uv_px is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_half_written_uv_is_ignored_rather_than_half_used():
|
||||||
|
document = to_document(
|
||||||
|
profile=PROFILE,
|
||||||
|
points=[SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4})],
|
||||||
|
)
|
||||||
|
document["points"][0]["uv_px"] = {"u": 5.0}
|
||||||
|
assert from_document(document, current_profile=PROFILE).points[0].uv_px is None
|
||||||
Reference in New Issue
Block a user