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
+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
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")
@@ -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