"""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, _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 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)