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:
@@ -0,0 +1,88 @@
|
||||
"""Candidate arrays on the flat map: the grid, the presets and the on-surface check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from orisys_autocal import array_plan
|
||||
from orisys_autocal.array_plan import (
|
||||
NEAR_EDGE,
|
||||
OFF_SURFACE,
|
||||
PRESETS,
|
||||
check_candidates,
|
||||
grid_uv,
|
||||
preset_uv,
|
||||
)
|
||||
|
||||
|
||||
def _flat(width: int = 200, height: int = 200, hole: tuple[int, int, int, int] | None = None):
|
||||
"""A fully covered raster, optionally with a rectangular hole (x0, y0, x1, y1)."""
|
||||
covered = np.ones((height, width), dtype=np.int32)
|
||||
if hole:
|
||||
x0, y0, x1, y1 = hole
|
||||
covered[y0:y1, x0:x1] = -1
|
||||
return SimpleNamespace(face_index=covered, width=width, height=height)
|
||||
|
||||
|
||||
def test_a_grid_is_centred_and_bottom_row_first():
|
||||
points = grid_uv(2, 3, 10, 20, centre=(0, 100), serpentine=False)
|
||||
assert points == [(-10, 90), (0, 90), (10, 90), (-10, 110), (0, 110), (10, 110)]
|
||||
|
||||
|
||||
def test_a_serpentine_walks_every_second_row_backwards():
|
||||
points = grid_uv(2, 3, 10, 20, centre=(0, 100), serpentine=True)
|
||||
assert points[3:] == [(10, 110), (0, 110), (-10, 110)]
|
||||
|
||||
|
||||
def test_an_empty_or_oversized_grid():
|
||||
assert grid_uv(0, 3, 10, 10) == []
|
||||
with pytest.raises(ValueError, match="more than"):
|
||||
grid_uv(30, 30, 1, 1)
|
||||
|
||||
|
||||
def test_presets_overlap_only_at_the_hardware_check_point():
|
||||
labels = [label for key in PRESETS for label, _u, _v in PRESETS[key]]
|
||||
assert sorted(label for label in set(labels) if labels.count(label) > 1) == ["C13"]
|
||||
# The first-model grid is centred on the hardware-check point.
|
||||
assert ("C13", 0, 470) in PRESETS["stage1"]
|
||||
assert ("C13", 0, 470) in PRESETS["stage0"]
|
||||
assert preset_uv("stage0") == [("C13", 0, 470)]
|
||||
with pytest.raises(ValueError):
|
||||
preset_uv("nope")
|
||||
assert array_plan.PRESET_KEYS[0] == "stage0"
|
||||
|
||||
|
||||
def test_a_point_on_the_surface_is_valid_and_off_it_is_not():
|
||||
flat = _flat(hole=(40, 40, 60, 60))
|
||||
origin = (100, 150) # raster (x, y) = (100 + u, 150 - v)
|
||||
result = check_candidates(flat, [(0, 100), (-50, 100)], origin=origin)
|
||||
assert [c.valid for c in result] == [True, False]
|
||||
assert result[1].reason == OFF_SURFACE # raster (50, 50) is in the hole
|
||||
|
||||
|
||||
def test_outside_the_raster_is_off_surface_not_an_error():
|
||||
result = check_candidates(_flat(), [(900, 900)], origin=(100, 150))
|
||||
assert result[0].valid is False and result[0].reason == OFF_SURFACE
|
||||
|
||||
|
||||
def test_a_margin_rejects_points_too_close_to_a_hole_or_the_border():
|
||||
flat = _flat(hole=(100, 60, 110, 70))
|
||||
origin = (100, 150)
|
||||
# raster (100, 75): five pixels below the hole's bottom edge.
|
||||
near = check_candidates(flat, [(0, 75)], margin_px=8, origin=origin)[0]
|
||||
far = check_candidates(flat, [(0, 75)], margin_px=3, origin=origin)[0]
|
||||
assert (near.valid, near.reason) == (False, NEAR_EDGE)
|
||||
assert far.valid
|
||||
# The raster border counts as an edge.
|
||||
assert check_candidates(flat, [(-98, 100)], margin_px=5, origin=origin)[0].reason == NEAR_EDGE
|
||||
|
||||
|
||||
def test_labels_survive_the_check_and_no_surface_vouches_for_nothing():
|
||||
flat = _flat()
|
||||
kept = check_candidates(flat, [("C13", 0, 100)], origin=(100, 150))[0]
|
||||
assert kept.label == "C13" and kept.valid
|
||||
none = check_candidates(None, [("C13", 0, 100), (1, 2)])
|
||||
assert [c.valid for c in none] == [False, False]
|
||||
assert none[0].label == "C13" and none[1].label == ""
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The array tool and the 2D map's look: what the operator sees and what the tool offers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_autocal.array_plan import Candidate
|
||||
from orisys_autocal.ui.sensor_panel import FlatSurfaceView
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
|
||||
def _synthetic_flat(size: int = 64):
|
||||
from orisys_autocal.sensor.uv_flat import FlatSurface
|
||||
|
||||
image = np.zeros((size, size, 3), dtype=np.uint8)
|
||||
image[..., 2] = 200 # a plain blue map: any yellow on it is a mark
|
||||
return FlatSurface(
|
||||
size_hw=(size, size),
|
||||
image_rgb=image,
|
||||
positions_mm=np.zeros((size, size, 3)),
|
||||
normals=np.tile(np.array([0.0, 0.0, 1.0]), (size, size, 1)),
|
||||
face_index=np.zeros((size, size), dtype=np.int32),
|
||||
uv_min=(0.0, 0.0),
|
||||
uv_max=(1.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def test_marks_stay_visible_when_the_map_is_scaled_down(qt_app):
|
||||
view = FlatSurfaceView()
|
||||
view.resize(32, 32) # half the map's size
|
||||
view.set_flat(_synthetic_flat(64))
|
||||
view.set_marker_colour("#ffd60a")
|
||||
view.set_sequence_points([(32, 32, "", False)])
|
||||
shown = view.pixmap().toImage()
|
||||
centre = QColor(shown.pixel(shown.width() // 2, shown.height() // 2))
|
||||
# The mark is drawn after the scale, so its colour is at the centre of the
|
||||
# shown image instead of being smeared into the blue by the downscale.
|
||||
assert centre.red() > 220 and centre.green() > 180 and centre.blue() < 80
|
||||
|
||||
|
||||
def test_the_background_can_be_changed_and_marks_survive_it(qt_app):
|
||||
view = FlatSurfaceView()
|
||||
view.resize(64, 64)
|
||||
view.set_flat(_synthetic_flat(64))
|
||||
before = QColor(view.pixmap().toImage().pixel(2, 2))
|
||||
for mode in ("gray", "dim", "outline"):
|
||||
view.set_background(mode)
|
||||
after = QColor(view.pixmap().toImage().pixel(2, 2))
|
||||
assert after != before, mode
|
||||
assert view.background == mode
|
||||
view.set_background("nonsense")
|
||||
assert view.background == "original"
|
||||
|
||||
|
||||
def test_unusable_candidates_are_drawn_apart_from_usable_ones(qt_app):
|
||||
view = FlatSurfaceView()
|
||||
view.resize(64, 64)
|
||||
view.set_flat(_synthetic_flat(64))
|
||||
view.set_marker_colour("#ffd60a")
|
||||
view.set_candidates([(32, 32, False)])
|
||||
shown = view.pixmap().toImage()
|
||||
red_pixels = sum(
|
||||
1
|
||||
for x in range(24, 41)
|
||||
for y in range(24, 41)
|
||||
if QColor(shown.pixel(x, y)).red() > 200 and QColor(shown.pixel(x, y)).green() < 110
|
||||
)
|
||||
assert red_pixels > 0
|
||||
|
||||
|
||||
def test_the_look_is_chosen_on_the_page_and_reported(make_window):
|
||||
window = make_window()
|
||||
panel = window.finger_panel
|
||||
seen: list[tuple[str, str]] = []
|
||||
panel.viewStyleChanged.connect(lambda mode, colour: seen.append((mode, colour)))
|
||||
panel.bg_combo.setCurrentIndex(panel.bg_combo.findData("dim"))
|
||||
panel.colour_combo.setCurrentIndex(panel.colour_combo.findData("white"))
|
||||
assert panel.view_style() == ("dim", "white")
|
||||
assert seen[-1] == ("dim", "white")
|
||||
assert window.prefs["finger_view_background"] == "dim"
|
||||
assert panel.view.marker_colour == QColor("#ffffff")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_grid_has_rows_times_columns_candidates_and_follows_its_fields(make_window):
|
||||
window = make_window()
|
||||
tool = window.sequence_panel.array_tool
|
||||
assert len(tool.candidates()) == 9
|
||||
tool.spin("rows").setValue(2)
|
||||
tool.spin("cols").setValue(5)
|
||||
assert len(tool.candidates()) == 10
|
||||
tool.mode_combo.setCurrentIndex(tool.mode_combo.findData("preset"))
|
||||
assert tool.mode == "preset"
|
||||
tool.preset_combo.setCurrentIndex(tool.preset_combo.findData("stage0"))
|
||||
assert tool.candidates() == [("C13", 0, 470)]
|
||||
window.close()
|
||||
|
||||
|
||||
def test_only_usable_candidates_can_be_added(make_window):
|
||||
window = make_window()
|
||||
tool = window.sequence_panel.array_tool
|
||||
added: list = []
|
||||
tool.addRequested.connect(added.append)
|
||||
tool.set_checked([Candidate(0, 0, False, "off_surface")])
|
||||
assert not tool.add_button.isEnabled()
|
||||
tool.set_checked([Candidate(0, 0, True), Candidate(5, 5, False, "near_edge")])
|
||||
assert tool.add_button.isEnabled()
|
||||
assert tool.add_button.text() == t("array.btn_add", count=1)
|
||||
tool.add_button.click()
|
||||
assert [c.u for c in added[0]] == [0]
|
||||
window.close()
|
||||
|
||||
|
||||
def test_adding_candidates_skips_a_point_already_in_the_sequence(make_window, monkeypatch):
|
||||
window = make_window()
|
||||
panel = window.sequence_panel
|
||||
monkeypatch.setattr(window, "_map_origin", lambda: (100, 150))
|
||||
monkeypatch.setattr(
|
||||
window.finger_panel,
|
||||
"solve_pixel_row",
|
||||
lambda x, y: {"X": 1.0, "Y": 2.0, "Z": 3.0, "u_px": x - 100.0, "v_px": 150.0 - y},
|
||||
)
|
||||
before = panel.point_row_count()
|
||||
window._on_array_add([Candidate(10, 20), Candidate(30, 40)]) # noqa: SLF001
|
||||
window._on_array_add([Candidate(10, 20), Candidate(50, 60)]) # noqa: SLF001
|
||||
assert panel.point_row_count() == before + 3
|
||||
window.close()
|
||||
@@ -188,7 +188,7 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi
|
||||
root, _, _ = overview_session
|
||||
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()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""U/V on sequence points: the optional field and the way it travels in a document."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from orisys_autocal.sequence_io import SequencePoint, from_document, to_document
|
||||
|
||||
PROFILE = {"mode": "plane", "pitch_mm": 2.0, "repeats": 1}
|
||||
|
||||
|
||||
def _round_trip(points):
|
||||
document = to_document(profile=PROFILE, points=points)
|
||||
# Through JSON text, as a file would carry it.
|
||||
return from_document(json.loads(json.dumps(document)), current_profile=PROFILE)
|
||||
|
||||
|
||||
def test_uv_survives_a_round_trip():
|
||||
point = SequencePoint(
|
||||
id=1, target_mm={"x": 1.0, "y": 2.0, "z": 3.0}, uv_px={"u": -130.0, "v": 470.5}
|
||||
)
|
||||
back = _round_trip([point]).points[0]
|
||||
assert back.uv_px == {"u": -130.0, "v": 470.5}
|
||||
|
||||
|
||||
def test_a_point_without_uv_stays_without_it():
|
||||
point = SequencePoint(id=1, target_mm={"x": 1.0, "y": 2.0, "z": 3.0})
|
||||
document = to_document(profile=PROFILE, points=[point])
|
||||
assert "uv_px" not in document["points"][0]
|
||||
assert _round_trip([point]).points[0].uv_px is None
|
||||
|
||||
|
||||
def test_an_older_file_with_no_uv_still_loads():
|
||||
document = to_document(
|
||||
profile=PROFILE,
|
||||
points=[SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4})],
|
||||
)
|
||||
assert from_document(document, current_profile=PROFILE).points[0].uv_px is None
|
||||
|
||||
|
||||
def test_a_half_written_uv_is_ignored_rather_than_half_used():
|
||||
document = to_document(
|
||||
profile=PROFILE,
|
||||
points=[SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4})],
|
||||
)
|
||||
document["points"][0]["uv_px"] = {"u": 5.0}
|
||||
assert from_document(document, current_profile=PROFILE).points[0].uv_px is None
|
||||
Reference in New Issue
Block a user