DXF compare: ancore ICP sul solo pezzo staccato dal bordo, errore ICP con motivo (#86)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
@@ -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)")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user