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
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2 # #86: soglia/componenti connesse per la maschera del pezzo
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from visionsuite.core.image import Image
|
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()
|
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.
|
"""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
|
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
|
angoli con i centroidi sovrapposti e l'ICP rifinisce. Ritorna le posa trovate
|
||||||
(anche per orientamenti che non convergono: le giudica la ricetta eseguita).
|
(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.calibration import ScaleCalibration
|
||||||
from visionsuite.dxf.compare import DXFCompare, _stack
|
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))
|
cmp_ = DXFCompare(dxf_path, ScaleCalibration(px_per_mm=px_per_mm))
|
||||||
drawing = _stack(cmp_.drawing_contours())
|
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)))
|
measured = _stack(cmp_.measured_contours(Image(gray)))
|
||||||
if len(drawing) < 3:
|
if len(drawing) < 3:
|
||||||
raise CostruttoreError("DXF senza geometria utilizzabile")
|
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)
|
dc, mc = drawing.mean(axis=0), measured.mean(axis=0)
|
||||||
|
|
||||||
anchors = []
|
anchors = []
|
||||||
|
errori = [] # #86: motivo di ogni orientamento caduto, per la diagnosi in campo
|
||||||
for theta in _START_ANGLES:
|
for theta in _START_ANGLES:
|
||||||
# traslazione che porta il centroide del disegno ruotato sul misurato
|
# traslazione che porta il centroide del disegno ruotato sul misurato
|
||||||
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
|
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
|
||||||
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
|
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
|
||||||
try:
|
try:
|
||||||
result = cmp_.compare(Image(gray), initial_pose=start)
|
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
|
continue
|
||||||
# transform: misurato -> disegno; la posa del disegno e' l'inversa
|
# transform: misurato -> disegno; la posa del disegno e' l'inversa
|
||||||
m = np.linalg.inv(result.transform)
|
m = np.linalg.inv(result.transform)
|
||||||
angle = math.degrees(math.atan2(m[1, 0], m[0, 0]))
|
angle = math.degrees(math.atan2(m[1, 0], m[0, 0]))
|
||||||
anchors.append((float(m[0, 2]), float(m[1, 2]), angle))
|
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
|
return anchors
|
||||||
|
|
||||||
|
|
||||||
@@ -199,7 +234,7 @@ def build_dxf_compare(
|
|||||||
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
|
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
|
||||||
|
|
||||||
reference_pose = _locate_reference(frame, polarity)
|
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:
|
if not anchors:
|
||||||
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
|
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from src.vision.costruttori import (
|
|||||||
CALIBRATION_FILE,
|
CALIBRATION_FILE,
|
||||||
DXF_FILE,
|
DXF_FILE,
|
||||||
CostruttoreError,
|
CostruttoreError,
|
||||||
|
_candidate_anchors,
|
||||||
|
_maschera_pezzo,
|
||||||
build_dxf_compare,
|
build_dxf_compare,
|
||||||
)
|
)
|
||||||
from src.vision.runner import run_graph
|
from src.vision.runner import run_graph
|
||||||
@@ -112,3 +114,37 @@ def test_dxf_illeggibile_solleva():
|
|||||||
def test_parametri_ricetta_invalidi(kwargs):
|
def test_parametri_ricetta_invalidi(kwargs):
|
||||||
with pytest.raises(CostruttoreError):
|
with pytest.raises(CostruttoreError):
|
||||||
build_dxf_compare(a_scene(), SAMPLE_DXF.read_bytes(), **kwargs)
|
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