- 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:
+99
-10
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user