diff --git a/src/vision/costruttori.py b/src/vision/costruttori.py index 1475be2..144aaba 100644 --- a/src/vision/costruttori.py +++ b/src/vision/costruttori.py @@ -17,6 +17,7 @@ import math import tempfile from pathlib import Path +import cv2 # #86: soglia/componenti connesse per la maschera del pezzo import numpy as np from visionsuite.core.image import Image @@ -113,12 +114,41 @@ def _locate_reference(gray: np.ndarray, polarity: str) -> dict: return pezzo.found_poses[0].to_dict() -def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> list[tuple[float, float, float]]: +def _maschera_pezzo(gray: np.ndarray, polarity: str, px_per_mm: float = 71.0) -> np.ndarray: + """Immagine con solo il pezzo: fuori dal blob staccato dal bordo e' sfondo (#86). + + Otsu (INV se dark), componenti connesse, la piu' grande che NON tocca il + bordo immagine (piastre del prisma, ombre e angoli neri della foto + raddrizzata ci sono attaccati), dilatata di ~0,3 mm (minimo 15 px). + """ + flag = cv2.THRESH_BINARY_INV if polarity == "dark" else cv2.THRESH_BINARY + _, binary = cv2.threshold(gray, 0, 255, flag | cv2.THRESH_OTSU) + n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) + h, w = gray.shape[:2] + best, best_area = 0, 0 + for i in range(1, n): # 0 = sfondo + x, y, cw, ch, area = (int(v) for v in stats[i]) + if x == 0 or y == 0 or x + cw >= w or y + ch >= h: + continue # tocca il bordo immagine: non e' il pezzo + if area > best_area: + best, best_area = i, area + if best == 0: + raise CostruttoreError("pezzo non trovato: nessun blob staccato dal bordo") + raggio = max(15, int(round(0.3 * px_per_mm))) # margine in funzione della scala + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2 * raggio + 1, 2 * raggio + 1)) + zona = cv2.dilate((labels == best).astype(np.uint8), kernel) > 0 + sfondo = 255 if polarity == "dark" else 0 + return np.where(zona, gray, np.uint8(sfondo)).astype(np.uint8) + + +def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float, + polarity: str = "dark") -> list[tuple[float, float, float]]: """Pose (riga, col, angolo) del disegno sull'immagine: ICP da 4 orientamenti. Il disegno e' in px (origine DXF, y capovolta); si parte da ognuno dei 4 angoli con i centroidi sovrapposti e l'ICP rifinisce. Ritorna le posa trovate (anche per orientamenti che non convergono: le giudica la ricetta eseguita). + Centroide e ICP lavorano sull'immagine mascherata sul solo pezzo (#86). """ from visionsuite.calibration import ScaleCalibration from visionsuite.dxf.compare import DXFCompare, _stack @@ -126,6 +156,7 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> li cmp_ = DXFCompare(dxf_path, ScaleCalibration(px_per_mm=px_per_mm)) drawing = _stack(cmp_.drawing_contours()) + gray = _maschera_pezzo(gray, polarity, px_per_mm) # #86: via piastre, ombre, angoli neri measured = _stack(cmp_.measured_contours(Image(gray))) if len(drawing) < 3: raise CostruttoreError("DXF senza geometria utilizzabile") @@ -134,18 +165,22 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> li dc, mc = drawing.mean(axis=0), measured.mean(axis=0) anchors = [] + errori = [] # #86: motivo di ogni orientamento caduto, per la diagnosi in campo for theta in _START_ANGLES: # traslazione che porta il centroide del disegno ruotato sul misurato rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1]) start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta) try: result = cmp_.compare(Image(gray), initial_pose=start) - except Exception: # noqa: BLE001 - un orientamento che cade non ferma gli altri + except Exception as exc: # noqa: BLE001 - un orientamento che cade non ferma gli altri + errori.append(f"{theta:g}gradi: {type(exc).__name__}: {exc}") # #86 continue # transform: misurato -> disegno; la posa del disegno e' l'inversa m = np.linalg.inv(result.transform) angle = math.degrees(math.atan2(m[1, 0], m[0, 0])) anchors.append((float(m[0, 2]), float(m[1, 2]), angle)) + if not anchors and errori: # #86: nessuna ancora -> il motivo risale al chiamante + raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito): " + "; ".join(errori)) return anchors @@ -199,7 +234,7 @@ def build_dxf_compare( raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica") reference_pose = _locate_reference(frame, polarity) - anchors = _candidate_anchors(frame, dxf_path, px_per_mm) + anchors = _candidate_anchors(frame, dxf_path, px_per_mm, polarity) # #86 if not anchors: raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)") diff --git a/src/vision/tests/test_costruttori.py b/src/vision/tests/test_costruttori.py index 0365abc..292b7c5 100644 --- a/src/vision/tests/test_costruttori.py +++ b/src/vision/tests/test_costruttori.py @@ -21,6 +21,8 @@ from src.vision.costruttori import ( CALIBRATION_FILE, DXF_FILE, CostruttoreError, + _candidate_anchors, + _maschera_pezzo, build_dxf_compare, ) from src.vision.runner import run_graph @@ -112,3 +114,37 @@ def test_dxf_illeggibile_solleva(): 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) +