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:
ykcheungab
2026-10-19 11:39:09 +08:00
parent 566f1bf390
commit ff5f089dc1
17 changed files with 1602 additions and 125 deletions
+55 -3
View File
@@ -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."
} }
} }
+55 -3
View File
@@ -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} 个求解失败。"
} }
} }
+1
View File
@@ -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.setFixedHeight(28) field.setObjectName("compactField")
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()
+134 -9
View File
@@ -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"),
) )
+88
View File
@@ -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 == ""
+127
View File
@@ -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()
+1 -1
View File
@@ -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()
+47
View File
@@ -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