1bd6c6b011
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
220 lines
9.3 KiB
Python
220 lines
9.3 KiB
Python
"""Costruttore di ricette dal campo: immagine + DXF + scala -> ricetta v2."""
|
|
import pytest
|
|
|
|
# Come test_runner.py: l'albero vision richiede Python >=3.13; su 3.11 e' skipped.
|
|
pytest.importorskip(
|
|
"visionsuite.task",
|
|
reason=(
|
|
"the vision tree requires Python >=3.13; run it with "
|
|
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
|
|
),
|
|
)
|
|
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from src.vision.costruttori import (
|
|
CALIBRATION_FILE,
|
|
DXF_FILE,
|
|
CostruttoreError,
|
|
_candidate_anchors,
|
|
_graph,
|
|
_maschera_pezzo,
|
|
build_dxf_compare,
|
|
)
|
|
from src.vision.runner import run_graph
|
|
|
|
SAMPLE_DXF = (
|
|
Path(__file__).resolve().parents[3]
|
|
/ "vendor/visionsuite/packages/vs-dxf/tests/fixtures/sample.dxf"
|
|
)
|
|
DXF_WIDTH_MM, DXF_HEIGHT_MM, DXF_HOLE_R_MM = 200.0, 100.0, 10.0
|
|
PX_PER_MM = 4.0
|
|
|
|
|
|
def a_scene(angle_deg: float = 0.0, shift_px: tuple[float, float] = (0.0, 0.0),
|
|
margin_mm: float = 60.0) -> np.ndarray:
|
|
"""Il pezzo del sample.dxf (scuro su bianco) a 4 px/mm, ruotato e traslato."""
|
|
w = int((DXF_WIDTH_MM + 2 * margin_mm) * PX_PER_MM)
|
|
h = int((DXF_HEIGHT_MM + 2 * margin_mm) * PX_PER_MM)
|
|
img = np.full((h, w), 255, np.uint8)
|
|
cx, cy = w // 2, h // 2
|
|
hw, hh = int(DXF_WIDTH_MM * PX_PER_MM / 2), int(DXF_HEIGHT_MM * PX_PER_MM / 2)
|
|
cv2.rectangle(img, (cx - hw, cy - hh), (cx + hw, cy + hh), 0, -1)
|
|
cv2.circle(img, (cx, cy), int(DXF_HOLE_R_MM * PX_PER_MM), 255, -1)
|
|
if angle_deg or any(shift_px):
|
|
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # >0 = antiorario a video
|
|
m[0, 2] += shift_px[0]
|
|
m[1, 2] += shift_px[1]
|
|
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
|
|
return img
|
|
|
|
|
|
# ------------------------------------------------------------------ ricetta DXF
|
|
|
|
def test_ricetta_dxf_gira_su_riferimento_e_su_scena_ruotata(tmp_path):
|
|
dxf = SAMPLE_DXF.read_bytes()
|
|
risultato = build_dxf_compare(a_scene(0.0), dxf, px_per_mm=PX_PER_MM, band_mm=2.0)
|
|
|
|
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0 # sul riferimento
|
|
assert set(risultato["outputs"]) == {
|
|
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
|
|
assert set(risultato["files"]) == {DXF_FILE, CALIBRATION_FILE}
|
|
assert json.loads(base64.b64decode(risultato["files"][CALIBRATION_FILE])) == {
|
|
"px_per_mm": PX_PER_MM}
|
|
assert risultato["overlay"] and risultato["outcome"] is not None
|
|
|
|
graph = risultato["graph"]
|
|
assert graph["schema_version"] == 2
|
|
# path come da contratto; lo sha256 lo esige vs-task per ogni file in meta
|
|
assert graph["meta"]["dxf"]["disegno"]["path"] == "disegno.dxf"
|
|
assert graph["meta"]["calibration"]["path"] == "calibrazione.json"
|
|
assert len(graph["meta"]["dxf"]["disegno"]["sha256"]) == 64
|
|
pezzo, dxf_tool = graph["tools"]
|
|
assert (pezzo["id"], pezzo["kind"], pezzo["name"]) == ("pezzo", "LocateBlob", "Pezzo")
|
|
assert (dxf_tool["id"], dxf_tool["kind"], dxf_tool["name"]) == ("dxf", "CompareDxf", "Confronto DXF")
|
|
assert dxf_tool["links"] == {"align": {"tool": "pezzo", "property": "poses"}}
|
|
|
|
# La cartella ricetta come la scriverebbe il backend, poi la scena ruotata di 5 gradi
|
|
for nome, b64 in risultato["files"].items():
|
|
(tmp_path / nome).write_bytes(base64.b64decode(b64))
|
|
ruotata = a_scene(5.0, shift_px=(30.0, 20.0))
|
|
esito = run_graph(ruotata, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
|
|
assert not esito.failures, esito.failures
|
|
assert esito.outputs["dxf.points_in_pct"] > 95.0
|
|
|
|
|
|
def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
|
|
# il riferimento non e' a 0 gradi: la ricetta si costruisce lo stesso
|
|
risultato = build_dxf_compare(a_scene(20.0, shift_px=(-15.0, 10.0)),
|
|
SAMPLE_DXF.read_bytes(), PX_PER_MM, band_mm=2.0)
|
|
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
|
|
|
|
|
|
# #127: pezzo a L (asimmetrico a 180 gradi: sample.dxf e' simmetrico e non distingue il
|
|
# capovolgimento). Un rettangolo con foro fuori centro NON basta: l'ICP di Core non lo risolve.
|
|
L_PROFILE_MM = [(-100, -50), (100, -50), (100, 50), (-20, 50), (-20, 10), (-100, 10)]
|
|
|
|
|
|
def _l_dxf(path: Path) -> bytes:
|
|
import ezdxf
|
|
|
|
doc = ezdxf.new()
|
|
doc.modelspace().add_lwpolyline(L_PROFILE_MM, close=True)
|
|
doc.saveas(str(path))
|
|
return path.read_bytes()
|
|
|
|
|
|
def _l_scene(angle_deg: float = 0.0) -> np.ndarray:
|
|
"""Il pezzo a L (scuro su bianco) a 4 px/mm; l'asse y del DXF e' verso l'alto."""
|
|
w, h = int(320 * PX_PER_MM), int(220 * PX_PER_MM)
|
|
cx, cy = w // 2, h // 2
|
|
img = np.full((h, w), 255, np.uint8)
|
|
pts = np.array([(cx + x * PX_PER_MM, cy - y * PX_PER_MM) for x, y in L_PROFILE_MM], np.int32)
|
|
cv2.fillPoly(img, [pts], 0)
|
|
if angle_deg:
|
|
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0)
|
|
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
|
|
return img
|
|
|
|
|
|
def _pct_a_180(tmp_path, togli_quarter: bool) -> tuple[float, float]:
|
|
"""points_in_pct della ricetta costruita a 0 gradi: (sulla scena dritta, a 180 gradi)."""
|
|
risultato = build_dxf_compare(_l_scene(0.0), _l_dxf(tmp_path / "l.dxf"), PX_PER_MM, band_mm=2.0)
|
|
graph = risultato["graph"]
|
|
if togli_quarter: # misura di controllo: senza pose_search il pezzo a 180 va capovolto
|
|
for tool in graph["tools"]:
|
|
tool["values"].pop("pose_search", None)
|
|
for nome, b64 in risultato["files"].items():
|
|
(tmp_path / nome).write_bytes(base64.b64decode(b64))
|
|
|
|
def pct(img):
|
|
esito = run_graph(img, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
|
|
assert not esito.failures, esito.failures
|
|
return esito.outputs["dxf.points_in_pct"]
|
|
return pct(_l_scene(0.0)), pct(_l_scene(180.0))
|
|
|
|
|
|
def test_ricetta_dxf_su_pezzo_capovolto_di_180(tmp_path):
|
|
# #127: con pose_search=quarter il pezzo a 180 gradi vale quanto quello dritto
|
|
pct_0, pct_180 = _pct_a_180(tmp_path, togli_quarter=False)
|
|
assert pct_0 >= 95.0
|
|
assert pct_180 >= 95.0 and abs(pct_180 - pct_0) <= 2.0
|
|
|
|
|
|
def test_ricetta_dxf_senza_quarter_il_pezzo_a_180_e_capovolto(tmp_path):
|
|
# #127: controllo del test sopra - senza pose_search il disegno finisce capovolto
|
|
_, pct_180 = _pct_a_180(tmp_path, togli_quarter=True)
|
|
assert pct_180 < 20.0 # misurato ~2,8% (Core#343 riporta 3,3% su PR165)
|
|
|
|
|
|
def test_graph_scrive_pose_search_quarter_nel_compare_dxf():
|
|
# #127: il CompareDxf porta pose_search=quarter solo se c'e' l'ancora
|
|
con = _graph("a" * 64, "b" * 64, 1.0, 95.0, "dark",
|
|
{"row": 0.0, "col": 0.0, "angle_deg": 0.0}, (1.0, 2.0, 3.0))
|
|
dxf_tool = next(t for t in con["tools"] if t["kind"] == "CompareDxf")
|
|
assert dxf_tool["values"]["pose_search"] == "quarter"
|
|
senza = _graph("a" * 64, "b" * 64, 1.0, 95.0, "dark",
|
|
{"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
|
|
assert [t["kind"] for t in senza["tools"]] == ["LocateBlob"]
|
|
|
|
|
|
def test_immagine_senza_pezzo_solleva():
|
|
bianca = np.full((600, 800), 255, np.uint8)
|
|
with pytest.raises(CostruttoreError, match="pezzo"):
|
|
build_dxf_compare(bianca, SAMPLE_DXF.read_bytes(), PX_PER_MM)
|
|
|
|
|
|
def test_dxf_illeggibile_solleva():
|
|
with pytest.raises(CostruttoreError, match="DXF"):
|
|
build_dxf_compare(a_scene(), b"questo non e' un dxf", PX_PER_MM)
|
|
|
|
|
|
@pytest.mark.parametrize("kwargs", [
|
|
{"px_per_mm": 0.0}, {"px_per_mm": PX_PER_MM, "band_mm": 0.0},
|
|
{"px_per_mm": PX_PER_MM, "min_points_pct": 0.0},
|
|
{"px_per_mm": PX_PER_MM, "min_points_pct": 101.0},
|
|
{"px_per_mm": PX_PER_MM, "polarity": "grigio"},
|
|
])
|
|
def test_parametri_ricetta_invalidi(kwargs):
|
|
with pytest.raises(CostruttoreError):
|
|
build_dxf_compare(a_scene(), SAMPLE_DXF.read_bytes(), **kwargs)
|
|
|
|
|
|
def test_maschera_pezzo_tiene_solo_il_blob_staccato_dal_bordo():
|
|
# #86: pezzo scuro staccato + rettangoli scuri attaccati al bordo + angolo nero
|
|
img = np.full((500, 600), 230, np.uint8)
|
|
img[200:300, 250:400] = 20 # il pezzo
|
|
img[0:60, 100:220] = 15 # attaccato al bordo alto
|
|
img[420:500, 300:420] = 15 # attaccato al bordo basso
|
|
img[0:80, 0:80] = 0 # angolo nero (borderValue=0 del raddrizzamento)
|
|
out = _maschera_pezzo(img, "dark", px_per_mm=4.0)
|
|
assert (out[200:300, 250:400] == 20).all() # il pezzo resta
|
|
assert (out[0:60, 100:220] == 255).all() # i rettangoli sono sfondo
|
|
assert (out[420:500, 300:420] == 255).all()
|
|
assert (out[0:80, 0:80] == 255).all() # angolo nero cancellato
|
|
|
|
|
|
def test_maschera_pezzo_senza_blob_staccato_solleva():
|
|
img = np.full((200, 200), 230, np.uint8)
|
|
img[:, 0:50] = 10 # solo una fascia attaccata al bordo
|
|
with pytest.raises(CostruttoreError, match="staccato dal bordo"):
|
|
_maschera_pezzo(img, "dark")
|
|
|
|
|
|
def test_ricetta_dxf_icp_fallito_riporta_il_motivo(monkeypatch, tmp_path):
|
|
# #86: l'errore "ICP fallito" porta il messaggio degli orientamenti caduti
|
|
from visionsuite.dxf.compare import DXFCompare
|
|
|
|
def boom(self, *a, **k):
|
|
raise ValueError("negative dimensions are not allowed")
|
|
|
|
monkeypatch.setattr(DXFCompare, "compare", boom)
|
|
with pytest.raises(CostruttoreError, match="ICP fallito.*negative dimensions"):
|
|
_candidate_anchors(a_scene(), SAMPLE_DXF, PX_PER_MM)
|
|
|