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>
This commit is contained in:
2026-10-25 16:11:18 +01:00
parent 4791c34205
commit 77419f9ebd
3 changed files with 167 additions and 1 deletions
+63
View File
@@ -184,6 +184,66 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
return anchors 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: def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder) 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)}") raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes: if not dxf_bytes:
raise CostruttoreError("DXF vuoto") 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) frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8") calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
+3 -1
View File
@@ -18,7 +18,7 @@ import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError 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 ( from src.vision.runner import (
GraphBuildError, GraphBuildError,
RunOutcome, RunOutcome,
@@ -315,6 +315,8 @@ def _dxf_preview(dxf_bytes: bytes) -> dict:
if not dxf_bytes: if not dxf_bytes:
raise CostruttoreError("DXF illeggibile: file vuoto") raise CostruttoreError("DXF illeggibile: file vuoto")
from visionsuite.io.dxf import read_xld_dxf 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: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "disegno.dxf" path = Path(tmp) / "disegno.dxf"
path.write_bytes(dxf_bytes) path.write_bytes(dxf_bytes)
@@ -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"]