From 77419f9ebd3de611ab2405656e282ec9db7da510 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sun, 25 Oct 2026 16:11:18 +0100 Subject: [PATCH] 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 --- src/vision/costruttori.py | 63 +++++++++++ src/vision_worker/main.py | 4 +- .../tests/test_chiudi_contorni.py | 101 ++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/vision_worker/tests/test_chiudi_contorni.py diff --git a/src/vision/costruttori.py b/src/vision/costruttori.py index 144aaba..6f12282 100644 --- a/src/vision/costruttori.py +++ b/src/vision/costruttori.py @@ -184,6 +184,66 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float, 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) @@ -211,6 +271,9 @@ def build_dxf_compare( 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") diff --git a/src/vision_worker/main.py b/src/vision_worker/main.py index 3e5d9eb..664ffa2 100644 --- a/src/vision_worker/main.py +++ b/src/vision_worker/main.py @@ -18,7 +18,7 @@ import numpy as np from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status from PIL import Image, UnidentifiedImageError -from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83 +from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni # #83 #103 from src.vision.runner import ( GraphBuildError, RunOutcome, @@ -315,6 +315,8 @@ def _dxf_preview(dxf_bytes: bytes) -> dict: if not dxf_bytes: raise CostruttoreError("DXF illeggibile: file vuoto") from visionsuite.io.dxf import read_xld_dxf + # #103: l'anteprima mostra cio' che il confronto usera' (contorni chiusi) + dxf_bytes, _ = chiudi_contorni(dxf_bytes) with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "disegno.dxf" path.write_bytes(dxf_bytes) diff --git a/src/vision_worker/tests/test_chiudi_contorni.py b/src/vision_worker/tests/test_chiudi_contorni.py new file mode 100644 index 0000000..99e7f32 --- /dev/null +++ b/src/vision_worker/tests/test_chiudi_contorni.py @@ -0,0 +1,101 @@ +"""#103: chiudi_contorni unisce LINE/ARC aperti in contorni chiusi (test sincroni, no pytest-asyncio).""" +import hashlib +import io +import tempfile +from pathlib import Path + +import cv2 +import ezdxf +import numpy as np +import pytest + +from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni +from src.vision_worker.main import _dxf_preview + + +def _dxf(riempi) -> bytes: + doc = ezdxf.new("R2010") + riempi(doc.modelspace()) + buf = io.StringIO() + doc.write(buf) + return buf.getvalue().encode("utf-8") + + +def _quattro_line(msp, w=20.0, h=10.0): + pts = [(0, 0), (w, 0), (w, h), (0, h)] + for i in range(4): + msp.add_line(pts[i], pts[(i + 1) % 4]) + + +def _entita(dxf: bytes): + with tempfile.TemporaryDirectory() as tmp: # readfile gestisce da solo codifica e CRLF + path = Path(tmp) / "o.dxf" + path.write_bytes(dxf) + return list(ezdxf.readfile(str(path)).modelspace()) + + +def test_chiudi_rettangolo_di_4_line_in_una_polilinea_chiusa(): + out, info = chiudi_contorni(_dxf(_quattro_line)) + assert info == {"anelli": 1, "entita_unite": 4, "entita_libere": 0} + ents = _entita(out) + assert [e.dxftype() for e in ents] == ["LWPOLYLINE"] and ents[0].closed + xs = [p[0] for p in ents[0].get_points()] + ys = [p[1] for p in ents[0].get_points()] + assert (min(xs), max(xs), min(ys), max(ys)) == (0, 20, 0, 10) # bbox invariato + + +def test_chiudi_asola_line_e_arc_ha_bulge(): + def asola(msp): # asola 20x10: due LINE orizzontali + due semicerchi r=5 + msp.add_line((5, 0), (15, 0)) + msp.add_arc((15, 5), 5, -90, 90) + msp.add_line((15, 10), (5, 10)) + msp.add_arc((5, 5), 5, 90, 270) + out, info = chiudi_contorni(_dxf(asola)) + assert info["anelli"] == 1 and info["entita_unite"] == 4 + ents = _entita(out) + assert len(ents) == 1 and ents[0].closed + assert any(abs(p[4]) > 1e-6 for p in ents[0].get_points()) # gli archi diventano bulge + + +def test_dxf_gia_chiuso_resta_identico(): + def chiuso(msp): + msp.add_lwpolyline([(0, 0), (20, 0), (20, 10), (0, 10)], close=True) + msp.add_circle((10, 5), 2) + dxf = _dxf(chiuso) + out, info = chiudi_contorni(dxf) + assert out == dxf and info["anelli"] == 0 + + +def test_line_isolata_resta_e_byte_identici(): + dxf = _dxf(lambda msp: msp.add_line((0, 0), (10, 0))) + out, info = chiudi_contorni(dxf) + assert out == dxf and info["anelli"] == 0 and info["entita_libere"] == 1 + + +def test_byte_non_dxf_ritornati_identici(): + brutto = b"questo non e' un dxf" + out, info = chiudi_contorni(brutto) + assert out == brutto and info["anelli"] == 0 + + +def test_preview_di_quattro_line_e_una_sola_entita(): + resp = _dxf_preview(_dxf(_quattro_line)) + assert resp["entita"] == 1 and resp["larghezza_mm"] == 20 and resp["altezza_mm"] == 10 + + +@pytest.mark.parametrize("band", [0.1, 0.3]) +def test_chiudi_build_dxf_compare_banda_larga_con_4_line(band): + # rettangolo scuro 20x10 mm su bianco, 20 px/mm; disegno = 4 LINE aperte + ppm = 20.0 + img = np.full((int(40 * ppm), int(60 * ppm)), 255, np.uint8) + cx, cy = img.shape[1] // 2, img.shape[0] // 2 + cv2.rectangle(img, (cx - int(10 * ppm), cy - int(5 * ppm)), + (cx + int(10 * ppm), cy + int(5 * ppm)), 0, -1) + dxf = _dxf(_quattro_line) + ris = build_dxf_compare(img, dxf, px_per_mm=ppm, band_mm=band) + assert ris["outputs"]["dxf.points_in_pct"] > 90.0 + # il disegno nella ricetta e' quello chiuso, non l'originale + import base64 + salvato = base64.b64decode(ris["files"]["disegno.dxf"]) + assert salvato != dxf and hashlib.sha256(salvato).hexdigest() == \ + ris["graph"]["meta"]["dxf"]["disegno"]["sha256"]