From ff5f089dc19bce0ccb59350d396c4c90a1f410ed Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 11:39:09 +0800 Subject: [PATCH] =?UTF-8?q?autocal:=20=E5=BA=8F=E5=88=97=E9=A1=B5=E9=98=B5?= =?UTF-8?q?=E5=88=97=E9=A2=84=E8=A7=88=E5=B7=A5=E5=85=B7=E3=80=812D=20?= =?UTF-8?q?=E5=9B=BE=E7=82=B9=E5=8F=AF=E8=A7=81=E5=BA=A6=EF=BC=8C=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A1=BA=E5=BA=8F=E4=B8=8E=E9=80=81=E5=88=B0=E6=A0=87?= =?UTF-8?q?=E5=AE=9A=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标签顺序:几何与安装 放到 测试序列 之前 - 序列点增加可选 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 --- apps/autocal/config/locales/en_US.json | 58 +++- apps/autocal/config/locales/zh_CN.json | 58 +++- apps/autocal/docs/sequence-format.md | 1 + apps/autocal/src/orisys_autocal/array_plan.py | 165 +++++++++ .../autocal/src/orisys_autocal/preferences.py | 4 + .../autocal/src/orisys_autocal/sequence_io.py | 12 + .../src/orisys_autocal/ui/array_tool.py | 268 +++++++++++++++ .../orisys_autocal/ui/calibration_panel.py | 61 ++++ .../src/orisys_autocal/ui/sensor_panel.py | 313 ++++++++++++++++-- .../src/orisys_autocal/ui/sequence_panel.py | 279 ++++++++++++---- apps/autocal/src/orisys_autocal/ui/window.py | 143 +++++++- .../src/orisys_autocal/ui/workspace.py | 6 +- apps/autocal/tests/test_array_plan.py | 88 +++++ apps/autocal/tests/test_array_tool.py | 127 +++++++ apps/autocal/tests/test_record_overview.py | 2 +- .../autocal/tests/test_sequence_to_capture.py | 95 ++++++ apps/autocal/tests/test_sequence_uv.py | 47 +++ 17 files changed, 1602 insertions(+), 125 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/array_plan.py create mode 100644 apps/autocal/src/orisys_autocal/ui/array_tool.py create mode 100644 apps/autocal/tests/test_array_plan.py create mode 100644 apps/autocal/tests/test_array_tool.py create mode 100644 apps/autocal/tests/test_sequence_to_capture.py create mode 100644 apps/autocal/tests/test_sequence_uv.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index aa7b112..39d4bea 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -494,7 +494,18 @@ "preview_solve_failed": "Solve failed: {error}", "loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit", "load_failed": "Load failed: {error}", - "picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°" + "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": { "title": "Test sequence design", @@ -556,7 +567,19 @@ "imported": "Sequence imported: {name} ({count} points)", "import_warnings": "Import reported {count} warning(s)", "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": { "ok": "Done", @@ -903,7 +926,8 @@ "queue_removed": "Point removed: {site}", "skipped": "Point skipped: {site}", "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": { "title": "Recording", @@ -974,5 +998,33 @@ "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_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." } } diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index b77a6a5..d13c649 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -494,7 +494,18 @@ "preview_solve_failed": "求解失败:{error}", "loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm", "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": { "title": "测试序列设计", @@ -556,7 +567,19 @@ "imported": "已导入序列: {name}({count} 点)", "import_warnings": "导入有 {count} 项提示", "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": { "ok": "完成", @@ -903,7 +926,8 @@ "queue_removed": "已删除测点 {site}", "skipped": "已跳过测点 {site}", "stopped": "已停止,已写入的数据保留。", - "done": "本场标定已结束:{path}" + "done": "本场标定已结束:{path}", + "queue_added_many": "已加入 {added} 个点到标定队列,{refused} 个未能加入。" }, "recording": { "title": "录像", @@ -974,5 +998,33 @@ "confirm_title": "写入 UNITS", "confirm_body": "将向控制器写入以下 UNITS:\n{changes}\n\n写入后,全部毫米和角度读数以及姿态移动的目标都按新当量重新计算。请确认平台已停止,并已核对机械传动比。", "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} 个求解失败。" } } diff --git a/apps/autocal/docs/sequence-format.md b/apps/autocal/docs/sequence-format.md index 25dfea1..9cf65dd 100644 --- a/apps/autocal/docs/sequence-format.md +++ b/apps/autocal/docs/sequence-format.md @@ -101,6 +101,7 @@ to another bench's number. | `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` | | `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 | | `enabled` | false = designed but deliberately skipped | | `note` | free text | diff --git a/apps/autocal/src/orisys_autocal/array_plan.py b/apps/autocal/src/orisys_autocal/array_plan.py new file mode 100644 index 0000000..486d848 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/array_plan.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/preferences.py b/apps/autocal/src/orisys_autocal/preferences.py index 51d32cf..17feb23 100644 --- a/apps/autocal/src/orisys_autocal/preferences.py +++ b/apps/autocal/src/orisys_autocal/preferences.py @@ -275,6 +275,10 @@ def default_preferences() -> dict[str, Any]: #: 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. "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 #: 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. diff --git a/apps/autocal/src/orisys_autocal/sequence_io.py b/apps/autocal/src/orisys_autocal/sequence_io.py index b89c736..e6141a0 100644 --- a/apps/autocal/src/orisys_autocal/sequence_io.py +++ b/apps/autocal/src/orisys_autocal/sequence_io.py @@ -92,6 +92,10 @@ class SequencePoint: id: int target_mm: dict[str, float] 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 enabled: bool = True note: str = "" @@ -106,6 +110,8 @@ class SequencePoint: } if self.sensor_mm is not None: 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 @@ -414,6 +420,7 @@ def _parse_points( if axis not in target: raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'") sensor = entry.get("sensor_mm") + uv = entry.get("uv_px") points.append( SequencePoint( id=int(_number(entry.get("id"), index)), @@ -423,6 +430,11 @@ def _parse_points( if isinstance(sensor, dict) 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), enabled=bool(entry.get("enabled", True)), note=str(entry.get("note") or ""), diff --git a/apps/autocal/src/orisys_autocal/ui/array_tool.py b/apps/autocal/src/orisys_autocal/ui/array_tool.py new file mode 100644 index 0000000..d263c8b --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/array_tool.py @@ -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() diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index 90f8fe4..dc38823 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -1058,6 +1058,67 @@ class CalibrationPanel(QWidget): self.set_message(t("cap.message.queue_added", site=site_id)) 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: """Retake the selected point at the current UV position. diff --git a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py index f1489b0..5252caf 100644 --- a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py @@ -27,10 +27,11 @@ from pathlib import Path import numpy as np from orisys_app_shared.i18n import t -from PySide6.QtCore import QPoint, Qt, Signal -from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap +from PySide6.QtCore import QPointF, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPainterPath, QPen, QPixmap from PySide6.QtWidgets import ( QCheckBox, + QComboBox, QDoubleSpinBox, QGridLayout, 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. _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): """The flat image, clickable, with the picked pixel cross-haired. @@ -80,6 +93,13 @@ class FlatSurfaceView(QLabel): self._pixmap: QPixmap | None = None self._marker: 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) @property @@ -90,6 +110,7 @@ class FlatSurfaceView(QLabel): self._flat = flat self._marker = None self._selected = None + self._bases = {} if flat is None: self._pixmap = None self.clear() @@ -143,55 +164,173 @@ class FlatSurfaceView(QLabel): return None return int(columns[best]), int(rows[best]) - def _marked(self) -> QPixmap | None: - """The native-size image with both markers drawn on it, or ``None``.""" - if self._pixmap is None: + # ------------------------------------------------------------------ styling + + 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 - if self._marker is None and self._selected is None: - return self._pixmap - canvas = self._pixmap.copy() + cached = self._bases.get(self._background) + if cached is not None: + 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.setRenderHint(QPainter.RenderHint.Antialiasing, True) 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: - x, y = self._marker - painter.setPen(QPen(Qt.GlobalColor.white, 1, Qt.PenStyle.SolidLine)) - painter.drawLine(x - 8, y, x + 8, y) - painter.drawLine(x, y - 8, x, y + 8) - painter.setPen(QPen(Qt.GlobalColor.black, 1, Qt.PenStyle.SolidLine)) - painter.drawEllipse(QPoint(x, y), 5, 5) + centre = at(*self._marker) + for width, pen_colour in ((4.0, QColor(0, 0, 0, 230)), (1.6, colour)): + painter.setPen(QPen(pen_colour, width)) + painter.drawLine(centre + QPointF(-9, 0), centre + QPointF(9, 0)) + painter.drawLine(centre + QPointF(0, -9), centre + QPointF(0, 9)) if self._selected is not None: - x, y = self._selected - painter.setPen(QPen(QColor("#ffffff"), 2)) - painter.setBrush(QColor("#d92d20")) - painter.drawEllipse(QPoint(x, y), 6, 6) + centre = at(*self._selected) + painter.setPen(QPen(QColor(0, 0, 0, 235), 4)) + painter.setBrush(QBrush()) + 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: painter.end() - return canvas def _refresh(self) -> None: - """Scale the marked image to fit the pane and show all of it.""" - marked = self._marked() - if marked is None: - return - if self._flat is None or marked.width() <= 0 or marked.height() <= 0: + """Scale the map to fit the pane, then draw the marks on the result.""" + base = self._base() + if base is None or base.width() <= 0 or base.height() <= 0: return available_w = max(1, self.width()) available_h = max(1, self.height()) - scale = min(available_w / marked.width(), available_h / marked.height()) - # Never magnify: a 320-pixel pane showing a 1024 image should not blur it - # up, and a pane larger than the image can centre it as-is. - scale = min(scale, 1.0) + # Never magnify: a pane larger than the image shows it as it is. + scale = min(available_w / base.width(), available_h / base.height(), 1.0) if scale >= 1.0: - self.setPixmap(marked) - return - self.setPixmap( - marked.scaled( - max(1, int(marked.width() * scale)), - max(1, int(marked.height() * scale)), + shown = base.copy() + else: + shown = base.scaled( + max(1, int(base.width() * scale)), + max(1, int(base.height() * scale)), Qt.AspectRatioMode.KeepAspectRatio, 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 super().resizeEvent(event) @@ -235,6 +374,9 @@ class FingerPanel(QGroupBox): #: Whether to ask before moving. Persisted; the window honours it for every #: pose move, including the rail's return-to-zero. 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__( self, @@ -361,6 +503,37 @@ class FingerPanel(QGroupBox): # that crops the UV space instead of shrinking it. 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.setObjectName("hintLabel") self.view_label.setWordWrap(True) @@ -876,6 +1049,16 @@ class FingerPanel(QGroupBox): self._sync_sim_button() self.refresh_readouts(point, normal, pose, face) 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 # ----------------------------------------------------------------- readouts @@ -1041,7 +1224,67 @@ class FingerPanel(QGroupBox): # ------------------------------------------------------------- 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: + 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.path_label.setText(t("finger.bundle_path")) self.load_button.setText(t("finger.load")) diff --git a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py index ed17a34..219f753 100644 --- a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py @@ -40,6 +40,7 @@ from PySide6.QtWidgets import ( QLabel, QLineEdit, QPushButton, + QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, @@ -53,6 +54,7 @@ from ..sequence_io import ( format_span, summarise, ) +from .array_tool import ArrayTool #: Profile fields, in form order: ``(key, locale_key, kind)``. #: ``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. -PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2 +PROFILE_ROWS = (len(PROFILE_FIELDS) + 2) // 3 #: Point-table columns: ``(key, locale_key)``. #: @@ -84,17 +86,25 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = ( ("x", "sequence.col_x"), ("y", "sequence.col_y"), ("z", "sequence.col_z"), + ("u", "sequence.col_u"), + ("v", "sequence.col_v"), ("status", "sequence.col_status"), ("sensitivity", "sequence.col_sensitivity"), ("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 #: coordinate is one that nothing has checked against the sensor. _EDITABLE_POINT_COLUMNS: tuple[str, ...] = () #: 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. PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run") @@ -151,6 +161,13 @@ class SequenceStep: class SequencePanel(QGroupBox): """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() simulateRequested = Signal() dryRunRequested = Signal() @@ -202,57 +219,70 @@ class SequencePanel(QGroupBox): box.setObjectName("groupBox") outer = QVBoxLayout(box) 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 # 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.setSpacing(8) self._name_label = QLabel(box) self._name_label.setObjectName("hintLabel") self.name_edit = QLineEdit(box) + self.name_edit.setObjectName("compactField") 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.setObjectName("hintLabel") self.note_edit = QLineEdit(box) + self.note_edit.setObjectName("compactField") self.note_edit.setFixedHeight(28) - outer.addWidget(self._note_label) - outer.addWidget(self.note_edit) + name_row.addWidget(self._name_label) + 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() - grid.setContentsMargins(0, 0, 0, 0) + # Two pages: where the points go (the array tool) and how each is measured + # (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.setVerticalSpacing(4) - outer.addLayout(grid) + grid.setVerticalSpacing(6) - # Two fields per row: label, field, label, field. Seeing the whole profile - # at once is what lets an operator sanity-check a pitch against a depth. + # Three label/field pairs per row, so the whole profile is visible at once + # (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): - row, side = divmod(index, 2) + row, side = divmod(index, 3) column = side * 2 - label = QLabel(box) + label = QLabel(params) label.setObjectName("hintLabel") grid.addWidget(label, row, column) self._profile_labels.append(label) if kind == "choice": - field: QWidget = QComboBox(box) - field.setFixedHeight(28) + field: QWidget = QComboBox(params) self._choices[key] = field # type: ignore[assignment] else: - field = QLineEdit(box) - field.setFixedHeight(28) + field = QLineEdit(params) + field.setObjectName("compactField") + field.setFixedHeight(28) field.setProperty("fieldKey", key) grid.addWidget(field, row, column + 1) self._profile_fields[key] = field - grid.setColumnStretch(1, 1) - grid.setColumnStretch(3, 1) + for column in (1, 3, 5): + grid.setColumnStretch(column, 1) + grid.setRowStretch(PROFILE_ROWS, 1) + self.profile_tabs.addTab(params, "") + outer.addWidget(self.profile_tabs, 1) self._profile_box = box return box @@ -392,7 +422,10 @@ class SequencePanel(QGroupBox): self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box) 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.setAlternatingRowColors(True) for index, width in enumerate(_POINT_COLUMN_WIDTHS): @@ -405,20 +438,7 @@ class SequencePanel(QGroupBox): self.table.setMinimumHeight(120) for row, (x, y, z) in enumerate(_EXAMPLE_POINTS): - values = { - 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) + self._write_row(row, str(row + 1), x, y, z, None, t("sequence.status_pending")) # 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 @@ -428,6 +448,27 @@ class SequencePanel(QGroupBox): self.summary_label.setWordWrap(True) 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) self.point_hint = QLabel(box) @@ -505,26 +546,108 @@ class SequencePanel(QGroupBox): def point_row_count(self) -> int: return self.table.rowCount() - def append_point(self, x: float, y: float, z: float, status: str = "") -> int: - """Add a generated point. Used by the generator once P5 exists.""" + def _write_row( + 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() self.table.insertRow(row) - values = { - 0: str(row + 1), - 1: f"{x:.3f}", - 2: f"{y:.3f}", - 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) + self._write_row(row, str(row + 1), x, y, z, uv, status or t("sequence.status_pending")) + self._dirty = True + self.refresh_summary() + self._sync_action_buttons() 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 def points(self) -> list[SequencePoint]: @@ -536,8 +659,14 @@ class SequencePanel(QGroupBox): target = {axis: float(cells[axis]) for axis in "xyz"} except (TypeError, ValueError): continue # a placeholder row that was never generated + uv = self.uv_of_row(row) 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 @@ -547,22 +676,23 @@ class SequencePanel(QGroupBox): for point in points: row = self.table.rowCount() self.table.insertRow(row) - values = { - 0: str(point.id), - 1: f"{point.target_mm.get('x', 0.0):.3f}", - 2: f"{point.target_mm.get('y', 0.0):.3f}", - 3: f"{point.target_mm.get('z', 0.0):.3f}", - 4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"), - 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) + uv = ( + None + if point.uv_px is None + else (float(point.uv_px.get("u", 0.0)), float(point.uv_px.get("v", 0.0))) + ) + self._write_row( + row, + str(point.id), + point.target_mm.get("x", 0.0), + point.target_mm.get("y", 0.0), + point.target_mm.get("z", 0.0), + uv, + t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"), + ) self._dirty = True self.refresh_summary() + self._sync_action_buttons() @property def dirty(self) -> bool: @@ -619,6 +749,9 @@ class SequencePanel(QGroupBox): for key, combo in self._choices.items(): _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_edit.setPlaceholderText(t("sequence.name_hint")) 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]) 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")): item.setText(t("sequence.status_pending")) 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() diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index d8a7f6e..3550433 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -56,7 +56,7 @@ from ..sensor import ( rasterise, 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 ( FILE_SUFFIX, SequenceDocument, @@ -402,6 +402,20 @@ class AutoCalMainWindow(QMainWindow): self.run_bar.abortRequested.connect(self._on_run_abort) self.sequence_panel.exportRequested.connect(self._on_export_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.pickRequested.connect(self._on_pick_point) 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.calibration_panel.set_flat(flat, str(path)) 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) info = bundle.describe() self._report_sequence( @@ -591,16 +608,124 @@ class AutoCalMainWindow(QMainWindow): # A UV gap or no bundle: the panel says which; do not imply success. self.statusBar().showMessage(self.finger_panel.status_label.text()) return - self._report_sequence( - t( - "finger.picked", - residual=f"{row['residual']:.3f}", - yaw=f"{row['yaw']:.2f}", - pitch=f"{row['pitch']:.2f}", - ), - ok=row["residual"] <= 0.5, + # Success is shown by the picker's own status line (see handle_pick); the + # strip at the top is for the platform and for actions, not for a pick. + + # ----------------------------------------------------------- array and map + + def _map_origin(self) -> tuple[int, int] | None: + flat = self.finger_panel.view.flat + 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: """The mounting pose, the tool position or the limits moved. diff --git a/apps/autocal/src/orisys_autocal/ui/workspace.py b/apps/autocal/src/orisys_autocal/ui/workspace.py index 20af95e..372705e 100644 --- a/apps/autocal/src/orisys_autocal/ui/workspace.py +++ b/apps/autocal/src/orisys_autocal/ui/workspace.py @@ -23,12 +23,12 @@ from .sequence_panel import SequencePanel from .record_overview_dialog import RecordOverviewPage #: 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 -#: sensor, capture data, then review calibration records. +#: follows the work: commission the axes, set up the geometry and mounting, design +#: the sequence against it, watch the sensor, capture data, then review records. TABS: tuple[tuple[str, str], ...] = ( ("axes", "workspace.tab_axes"), - ("sequence", "workspace.tab_sequence"), ("finger", "workspace.tab_finger"), + ("sequence", "workspace.tab_sequence"), ("sensor", "workspace.tab_sensor"), ("records", "workspace.tab_records"), ) diff --git a/apps/autocal/tests/test_array_plan.py b/apps/autocal/tests/test_array_plan.py new file mode 100644 index 0000000..cc305c7 --- /dev/null +++ b/apps/autocal/tests/test_array_plan.py @@ -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 == "" diff --git a/apps/autocal/tests/test_array_tool.py b/apps/autocal/tests/test_array_tool.py new file mode 100644 index 0000000..19c9445 --- /dev/null +++ b/apps/autocal/tests/test_array_tool.py @@ -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() diff --git a/apps/autocal/tests/test_record_overview.py b/apps/autocal/tests/test_record_overview.py index d26ecf8..a4c9a12 100644 --- a/apps/autocal/tests/test_record_overview.py +++ b/apps/autocal/tests/test_record_overview.py @@ -188,7 +188,7 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi root, _, _ = overview_session monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root)) 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() assert window.workspace.current_key() == "records" page = window.workspace.page("records") diff --git a/apps/autocal/tests/test_sequence_to_capture.py b/apps/autocal/tests/test_sequence_to_capture.py new file mode 100644 index 0000000..c81bd84 --- /dev/null +++ b/apps/autocal/tests/test_sequence_to_capture.py @@ -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() diff --git a/apps/autocal/tests/test_sequence_uv.py b/apps/autocal/tests/test_sequence_uv.py new file mode 100644 index 0000000..87772e2 --- /dev/null +++ b/apps/autocal/tests/test_sequence_uv.py @@ -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