feat: input DXF + ROI poligonale + export JSON + CI Gitea
CI / test (push) Failing after 32s

- pm2d/dxf.py: rasterizzazione DXF -> template (ezdxf, flattening
  entita', scala/centratura, render edge antialiased)
- POST /upload_dxf: carica CAD come modello (size 128..2048)
- roi_poly su /match, /match_simple e POST /recipes: train con mask
  cv2.fillPoly (validazione 400 su poligoni degeneri), cache key inclusa
- UI: upload .dxf, modalita' ROI poligonale su canvas (click=vertice,
  dblclick=chiudi, reset), bottone Esporta JSON dei risultati
- .gitea/workflows/ci.yml: uv sync + ruff + pytest su push/PR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:30:46 +00:00
parent 91a6beb032
commit e3114d6255
5 changed files with 445 additions and 13 deletions
+99 -10
View File
@@ -55,6 +55,7 @@ RECIPES_DIR.mkdir(exist_ok=True)
from pm2d.line_matcher import LineShapeMatcher, Match
from pm2d.auto_tune import auto_tune
from pm2d.dxf import dxf_to_image
WEB_DIR = Path(__file__).parent
@@ -91,7 +92,10 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
"min_feature_spacing",
"angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_step",
"spread_radius", "pyramid_levels")
"spread_radius", "pyramid_levels",
# ROI poligonale: la mask cambia il training a parità di
# bbox → deve invalidare la cache (None = ROI rettangolare)
"roi_poly")
for k in relevant:
h.update(f"{k}={tech.get(k)}".encode())
h.update(f"shape={roi.shape}".encode())
@@ -170,6 +174,39 @@ def _clamp_roi(x: int, y: int, w: int, h: int,
return x, y, w, h
def _poly_bbox_mask(
roi_poly: list[list[float]], img_w: int, img_h: int,
) -> tuple[int, int, int, int, np.ndarray]:
"""Valida roi_poly (vertici [x, y] in coordinate IMMAGINE) e ritorna
(x, y, w, h, mask): bbox del poligono clampato con _clamp_roi e mask
uint8 (255 dentro il poligono) nel sistema di coordinate della ROI.
Solleva 400 se il poligono ha <3 punti o area degenere.
"""
pts = np.asarray(roi_poly, dtype=np.float64)
if pts.ndim != 2 or pts.shape[1] != 2 or pts.shape[0] < 3:
raise HTTPException(
400, "roi_poly non valido: servono almeno 3 vertici [x, y]")
# Area con formula shoelace: poligoni collineari/degeneri → 400
px_, py_ = pts[:, 0], pts[:, 1]
area = 0.5 * abs(np.dot(px_, np.roll(py_, 1)) - np.dot(py_, np.roll(px_, 1)))
if area < 16.0:
raise HTTPException(
400, f"roi_poly degenere: area {area:.1f} px² troppo piccola")
x0 = int(np.floor(px_.min())); y0 = int(np.floor(py_.min()))
bw = int(np.ceil(px_.max())) - x0
bh = int(np.ceil(py_.max())) - y0
x, y, w, h = _clamp_roi(x0, y0, bw, bh, img_w, img_h)
# Mask nel sistema ROI: vertici ritraslati di (-x, -y)
mask = np.zeros((h, w), dtype=np.uint8)
local = np.round(pts - [x, y]).astype(np.int32)
cv2.fillPoly(mask, [local], 255)
if not mask.any():
raise HTTPException(
400, "roi_poly fuori immagine: nessun pixel utile nella mask")
return x, y, w, h, mask
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti.
@@ -303,6 +340,10 @@ class MatchParams(BaseModel):
model_id: str
scene_id: str
roi: list[int] # [x, y, w, h] nell'immagine modello
# ROI poligonale opzionale: vertici [x, y] in coordinate IMMAGINE
# (min 3 punti). Se presente, il bbox del poligono sostituisce `roi`
# e il training usa la mask del poligono.
roi_poly: list[list[float]] | None = None
angle_min: float = 0.0
angle_max: float = 360.0
angle_step: float = 5.0
@@ -384,6 +425,8 @@ class SimpleMatchParams(BaseModel):
model_id: str
scene_id: str
roi: list[int]
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
roi_poly: list[list[float]] | None = None
tipo: str = "intero" # "intero" | "parziale"
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
scala: str = "fissa" # chiave SCALE_PRESETS
@@ -603,6 +646,26 @@ async def upload(file: UploadFile = File(...)):
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
@app.post("/upload_dxf", response_model=UploadResp)
async def upload_dxf(file: UploadFile = File(...), size: int = 512):
"""Upload DXF: rasterizza il CAD in template grayscale e lo salva
nella cache immagini come un normale upload.
Query param `size` = lato del canvas (clamp 128..2048).
"""
size = max(128, min(2048, int(size)))
data = await file.read()
try:
gray = dxf_to_image(data, target_size=size)
except ValueError as e:
raise HTTPException(400, f"DXF non valido: {e}")
# _store_image salva PNG e gli endpoint a valle (cvtColor BGR2GRAY su
# roi_img, _load_image con IMREAD_COLOR) si aspettano 3 canali → BGR.
img = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
iid = _store_image(img)
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
@app.get("/image/{iid}/raw")
def image_raw(iid: str):
img = _load_image(iid)
@@ -617,8 +680,14 @@ def match(p: MatchParams):
scene = _load_image(p.scene_id)
if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
tech_for_cache = {
@@ -630,6 +699,9 @@ def match(p: MatchParams):
"scale_step": p.scale_step,
"spread_radius": p.spread_radius,
"pyramid_levels": p.pyramid_levels,
# Tuple per repr stabile nella cache key (None = rettangolare)
"roi_poly": (tuple(map(tuple, p.roi_poly))
if p.roi_poly is not None else None),
}
key = _matcher_cache_key(roi_img, tech_for_cache)
# Lock globale: matcher condivisi tra thread del pool FastAPI
@@ -646,7 +718,7 @@ def match(p: MatchParams):
spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m)
else:
@@ -689,11 +761,20 @@ def match_simple(p: SimpleMatchParams):
scene = _load_image(p.scene_id)
if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, roi_img)
# Tuple per repr stabile nella cache key (None = rettangolare)
tech["roi_poly"] = (tuple(map(tuple, p.roi_poly))
if p.roi_poly is not None else None)
key = _matcher_cache_key(roi_img, tech)
# Halcon-mode init params: incidono sul training, includere in cache key
@@ -716,7 +797,7 @@ def match_simple(p: SimpleMatchParams):
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m)
else:
@@ -778,6 +859,8 @@ class SaveRecipeParams(BaseModel):
model_id: str
scene_id: str | None = None
roi: list[int]
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
roi_poly: list[list[float]] | None = None
# Riusa stessi param simple per training equivalente
tipo: str = "intero"
simmetria: str = "nessuna"
@@ -897,8 +980,14 @@ def save_recipe(p: SaveRecipeParams):
model = _load_image(p.model_id)
if model is None:
raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
sp = SimpleMatchParams(
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
@@ -925,7 +1014,7 @@ def save_recipe(p: SaveRecipeParams):
)
# Lock globale: serializza il training pesante col matching in corso
with _MATCHER_LOCK:
n_var = m.train(roi_img)
n_var = m.train(roi_img, train_mask)
_check_trained(m, n_var)
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name: