Files
TieMeasureFlow/src/vision/costruttori.py
T
Adriano 77419f9ebd vision: il DXF di confronto unisce le entita' aperte in contorni chiusi (Refs #103)
chiudi_contorni unisce LINE/ARC aperti in LWPOLYLINE chiuse prima di
costruire la ricetta e per l'anteprima: con banda larga cut_open_ends
non lasciava piu' punti in banda.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:11:18 +01:00

327 lines
14 KiB
Python

"""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 chiudi_contorni(dxf_bytes: bytes) -> tuple[bytes, dict]:
"""#103: unisce le entita' aperte (LINE/ARC/polilinee aperte in fila) in contorni chiusi.
CompareDxf taglia il contorno misurato agli estremi di ogni entita' aperta
del disegno (cut_open_ends): con un profilo spezzato in LINE+ARC e banda
larga non resta nessun punto in banda e il confronto fallisce. Qui le catene
chiuse diventano una LWPOLYLINE chiusa (con bulge per gli archi).
Pura e senza eccezioni nuove: se non c'e' nulla da unire, o ezdxf non legge
il file, ritorna i byte originali (sha invariato, ricette esistenti stabili).
Ritorna (bytes, {"anelli", "entita_unite", "entita_libere"}).
"""
nulla = {"anelli": 0, "entita_unite": 0, "entita_libere": 0}
try:
import ezdxf
from ezdxf import edgeminer as em, edgesmith as es
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.dxf"
src.write_bytes(dxf_bytes)
doc = ezdxf.readfile(str(src))
msp = doc.modelspace()
# edges_from_entities_2d salta le entita' gia' chiuse (restano dove sono)
edges = list(es.edges_from_entities_2d(msp))
if not edges:
return dxf_bytes, nulla
loops = em.find_all_loops(em.Deposit(edges, gap_tol=0.01)) # 0.01 mm: giunzioni CAD
usati: set = set()
unite: list = [] # (polilinea, entita' di partenza)
# loop piu' lungo per primo, ogni edge usato una sola volta
for loop in sorted(loops, key=lambda lp: -sum(e.length for e in lp)):
ids = {e.id for e in loop}
if ids & usati:
continue
usati |= ids
unite.append((loop, es.lwpolyline_from_chain(loop)))
if not unite:
return dxf_bytes, {**nulla, "entita_libere": len(edges)}
entita_unite = 0
for loop, pl in unite:
pl.closed = True
payload = getattr(loop[0], "payload", None)
layer = getattr(getattr(payload, "dxf", None), "layer", None)
if layer:
pl.dxf.layer = layer # stesso layer dell'entita' di partenza
for e in loop:
ent = getattr(e, "payload", None)
if ent is not None and ent.is_alive:
msp.delete_entity(ent) # sostituita dalla polilinea chiusa
msp.add_foreign_entity(pl)
entita_unite += len(loop)
dst = Path(tmp) / "out.dxf"
doc.saveas(str(dst))
info = {"anelli": len(unite), "entita_unite": entita_unite,
"entita_libere": len(edges) - entita_unite}
return dst.read_bytes(), info
except Exception: # noqa: BLE001 - DXF illeggibile: l'errore resta quello a valle
return dxf_bytes, nulla
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")
# #103: entita' aperte unite in contorni chiusi PRIMA dello sha: disegno.dxf,
# grafo e runtime usano tutti il DXF chiuso
dxf_bytes, _info_chiusura = chiudi_contorni(dxf_bytes)
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,
}