"""Costruttore di ricette DXF compare dal campo (immagine + DXF + scala -> ricetta v2). Logica pura, niente FastAPI ne' database: il worker (src/vision_worker/main.py) lo espone come /recipes/dxf-compare, i test lo chiamano direttamente. Tutta la visione sta in TieVision-Core (visionsuite.*). La calibrazione a scacchiera NON sta qui: vive nell'acquisitore (scripts/calibrazione.py, #83). Errori di input (pezzo non trovato, DXF illeggibile, parametri fuori range) sono `CostruttoreError`: il worker li mappa a 422. """ from __future__ import annotations import base64 import hashlib import json 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 from src.vision.runner import RunOutcome, run_graph class CostruttoreError(ValueError): """Input che non permette di costruire (422 per il worker), non un guasto.""" # Nomi/id fissi della ricetta (contratto #83 con backend e frontend). DXF_FILE = "disegno.dxf" CALIBRATION_FILE = "calibrazione.json" _POLARITIES = ("dark", "light") # Pose iniziali del disegno provate (gradi): il verso del pezzo non e' noto. _OUTPUT_KEYS = ("dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max") _START_ANGLES = (0.0, 90.0, 180.0, 270.0) def _b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") def _gray_u8(pixels: np.ndarray) -> np.ndarray: """Immagine -> grigio uint8 2D (LocateBlob accetta solo uint8).""" px = np.asarray(pixels) if px.ndim == 3: px = px[..., 0] # multicanale: il primo, come fa LocateBlob if px.dtype != np.uint8: # float/uint16 -> scala sul massimo, come _to_gray_uint8 di vs-core m = float(px.max()) if px.size else 1.0 px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8) return np.ascontiguousarray(px) # ------------------------------------------------------------------ W2 def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, polarity: str, reference_pose: dict, anchor: tuple[float, float, float] | None) -> dict: """Ricetta v2 fissa: LocateBlob "pezzo" + CompareDxf "dxf" agganciato a `align`. `anchor` = (riga, colonna, angolo) dell'origine del disegno nell'immagine di riferimento; None = solo il localizzatore (passo intermedio). """ tools: list[dict] = [{ "id": "pezzo", "name": "Pezzo", "kind": "LocateBlob", "values": {"polarity": polarity, "reference_pose": reference_pose}, "links": {}, }] if anchor is not None: row, col, angle = anchor tools.append({ "id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf", "values": { "dxf": "disegno", "anchor": "align", "anchor_offset_row_px": float(row), "anchor_offset_col_px": float(col), "offset_angle_deg": float(angle), "band_plus": float(band_mm), "band_minus": float(band_mm), "nominal_unit": "mm", # tolleranza di points_in_pct: nominale = limite, plus = 100 - limite "points_in_pct_nominal": float(min_points_pct), "points_in_pct_plus": float(100.0 - min_points_pct), "points_in_pct_minus": 0.0, }, "links": {"align": {"tool": "pezzo", "property": "poses"}}, }) return { "schema_version": 2, "name": "Confronto DXF", # sha256 obbligatorio per vs-task (verifica i file della cartella ricetta) "meta": {"dxf": {"disegno": {"path": DXF_FILE, "sha256": dxf_sha}}, "calibration": {"path": CALIBRATION_FILE, "sha256": cal_sha}}, "tools": tools, } def _locate_reference(gray: np.ndarray, polarity: str) -> dict: """Posa del pezzo sull'immagine di riferimento (LocateBlob, soglia Otsu).""" from visionsuite.task import Task, load_tools load_tools() graph = _graph('', '', 1.0, 95.0, polarity, {"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None) task = Task.from_dict(graph) task.run(context={"image": Image(gray)}) pezzo = task["Pezzo"] if not pezzo.found_poses: raise CostruttoreError( "pezzo non trovato nell'immagine di riferimento " f"(polarity={polarity}): controllare luce, sfondo e polarita'" ) return pezzo.found_poses[0].to_dict() 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 from visionsuite.task.pose import Pose 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") if len(measured) < 3: raise CostruttoreError("pezzo non trovato: nessun bordo nell'immagine") 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 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 def _run(gray, graph, px_per_mm, folder) -> RunOutcome: return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder) def build_dxf_compare( gray, dxf_bytes: bytes, px_per_mm: float, band_mm: float = 0.1, min_points_pct: float = 95.0, polarity: str = "dark", ) -> dict: """Ricetta DXF compare dall'immagine di riferimento + disegno + scala. Ritorna {graph, files, outputs, overlay, outcome}. `files` sono i due file della cartella ricetta (b64): disegno.dxf e calibrazione.json. """ if not (px_per_mm > 0 and math.isfinite(px_per_mm)): raise CostruttoreError("px_per_mm deve essere > 0") if not (band_mm > 0 and math.isfinite(band_mm)): raise CostruttoreError("band_mm deve essere > 0") if not (0 < min_points_pct <= 100): raise CostruttoreError("min_points_pct deve stare in (0, 100]") if polarity not in _POLARITIES: raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}") if not dxf_bytes: raise CostruttoreError("DXF vuoto") frame = _gray_u8(gray) calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8") dxf_sha = hashlib.sha256(dxf_bytes).hexdigest() cal_sha = hashlib.sha256(calibration_bytes).hexdigest() with tempfile.TemporaryDirectory() as tmp: folder = Path(tmp) dxf_path = folder / DXF_FILE dxf_path.write_bytes(dxf_bytes) (folder / CALIBRATION_FILE).write_bytes(calibration_bytes) # DXF leggibile? (ezdxf solleva su file corrotto/non DXF) from visionsuite.io.dxf import read_xld_dxf try: drawing = list(read_xld_dxf(str(dxf_path), sampling_distance=0.1, scale=px_per_mm, flip_y=True)) except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc if not drawing: raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica") reference_pose = _locate_reference(frame, polarity) anchors = _candidate_anchors(frame, dxf_path, px_per_mm, polarity) # #86 if not anchors: raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)") # Verita' = eseguire davvero la ricetta sul riferimento: vince il maggior points_in_pct best: tuple[float, dict, RunOutcome] | None = None for anchor in anchors: graph = _graph(dxf_sha, cal_sha, band_mm, min_points_pct, polarity, reference_pose, anchor) outcome = _run(frame, graph, px_per_mm, folder) pct = outcome.outputs.get("dxf.points_in_pct") if pct is not None and (best is None or pct > best[0]): best = (pct, graph, outcome) if best is None: raise CostruttoreError( "disegno non sovrapponibile al pezzo: il confronto non ha prodotto punti" ) _, graph, outcome = best # solo le quattro uscite del contratto (n_points resta fuori) outputs = {k: v for k, v in outcome.outputs.items() if k in _OUTPUT_KEYS} return { "graph": graph, "files": {DXF_FILE: _b64(dxf_bytes), CALIBRATION_FILE: _b64(calibration_bytes)}, "outputs": outputs, "overlay": outcome.overlay, "outcome": outcome.outcome, }