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
+31
View File
@@ -0,0 +1,31 @@
# CI Gitea Actions: lint (ruff) + test sintetici (pytest).
# I test non richiedono le immagini in Test/ (sono generati a runtime).
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Installa uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync dipendenze
run: uv sync
- name: Lint (ruff)
# Ignore da CLI (pyproject.toml non va toccato): E501/E741 +
# stile pre-esistente del progetto (E702 statement con ';',
# E402 import dopo setup env, F841/F401 nei moduli legacy).
run: uv run ruff check pm2d/
- name: Test (pytest)
run: uv run pytest tests/ -v
+119
View File
@@ -0,0 +1,119 @@
"""Rasterizzazione DXF → immagine template per il matcher shape-based.
Il matcher lavora sui gradienti degli edge: un line-drawing pulito
(sfondo grigio scuro, tratti chiari) è un template perfettamente valido.
Questo modulo converte un file DXF (CAD 2D) in una bitmap grayscale
centrata e scalata, pronta per train().
"""
from __future__ import annotations
import io
import cv2
import numpy as np
# Valori di rendering: sfondo scuro / tratto chiaro → gradiente netto
BG_GRAY = 60
LINE_GRAY = 220
def _read_doc(data: bytes):
"""Parse DXF da bytes con gestione encoding.
Prima prova ezdxf.read su StringIO (DXF ASCII utf-8 / cp1252),
poi fallback su ezdxf.recover che auto-rileva encoding e tollera
file malformati.
"""
import ezdxf
from ezdxf import recover
for enc in ("utf-8", "cp1252"):
try:
text = data.decode(enc)
return ezdxf.read(io.StringIO(text))
except Exception:
# UnicodeDecodeError, DXFStructureError e simili: prossimo tentativo
continue
# Ultimo tentativo: recover lavora direttamente sui bytes
try:
doc, _auditor = recover.read(io.BytesIO(data))
return doc
except Exception as e:
raise ValueError(f"DXF illeggibile o corrotto: {e}") from e
def _extract_polylines(doc, flatten_dist: float = 0.05) -> tuple[list[np.ndarray], int]:
"""Converte le entità del modelspace in polilinee (liste di punti XY).
Entità non convertibili (non supportate da make_path) vengono saltate
silenziosamente ma conteggiate. Ritorna (polilinee, n_saltate).
"""
from ezdxf import path as ezpath
polylines: list[np.ndarray] = []
skipped = 0
for entity in doc.modelspace():
try:
p = ezpath.make_path(entity)
pts = np.array(
[(v.x, v.y) for v in p.flattening(distance=flatten_dist)],
dtype=np.float64,
)
if len(pts) >= 2:
polylines.append(pts)
except Exception:
skipped += 1
return polylines, skipped
def dxf_to_image(data: bytes, target_size: int = 512,
line_thickness: int = 2, margin: int = 16) -> np.ndarray:
"""Rasterizza un DXF in immagine grayscale (H, W) uint8.
- Scala uniforme: il lato lungo del disegno = target_size - 2*margin.
- Disegno centrato, asse Y CAD (su) ribaltato in convenzione immagine.
- Sfondo grigio scuro (60), tratti chiari (220), antialiased.
Solleva ValueError se il DXF è vuoto o illeggibile.
"""
doc = _read_doc(data)
# Distanza di flattening provvisoria in unità CAD: raffinata sotto
# una volta nota la scala (qui serve solo per il bounding box).
polylines, skipped = _extract_polylines(doc)
if not polylines:
raise ValueError(
"DXF vuoto: nessuna entità convertibile in polilinea nel "
f"modelspace ({skipped} entità non supportate saltate)")
all_pts = np.vstack(polylines)
min_xy = all_pts.min(axis=0)
max_xy = all_pts.max(axis=0)
extent = max_xy - min_xy
long_side = float(extent.max())
if long_side <= 0:
raise ValueError("DXF degenere: bounding box con estensione nulla")
# Ri-flattening con distanza adattiva: ~0.25 px di errore alla scala
# finale (il primo pass usava una tolleranza in unità CAD arbitraria).
avail = max(1, target_size - 2 * margin)
scale = avail / long_side
polylines, _ = _extract_polylines(doc, flatten_dist=max(1e-9, 0.25 / scale))
canvas = np.full((target_size, target_size), BG_GRAY, dtype=np.uint8)
# Offset per centrare il disegno (anche sul lato corto)
draw_w = extent[0] * scale
draw_h = extent[1] * scale
off_x = (target_size - draw_w) / 2.0
off_y = (target_size - draw_h) / 2.0
for pts in polylines:
px = (pts[:, 0] - min_xy[0]) * scale + off_x
# Y CAD verso l'alto → Y immagine verso il basso
py = (max_xy[1] - pts[:, 1]) * scale + off_y
ipts = np.stack([px, py], axis=1).round().astype(np.int32)
cv2.polylines(canvas, [ipts], isClosed=False,
color=LINE_GRAY, thickness=line_thickness,
lineType=cv2.LINE_AA)
return canvas
+93 -4
View File
@@ -55,6 +55,7 @@ RECIPES_DIR.mkdir(exist_ok=True)
from pm2d.line_matcher import LineShapeMatcher, Match from pm2d.line_matcher import LineShapeMatcher, Match
from pm2d.auto_tune import auto_tune from pm2d.auto_tune import auto_tune
from pm2d.dxf import dxf_to_image
WEB_DIR = Path(__file__).parent WEB_DIR = Path(__file__).parent
@@ -91,7 +92,10 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
"min_feature_spacing", "min_feature_spacing",
"angle_min", "angle_max", "angle_step", "angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_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: for k in relevant:
h.update(f"{k}={tech.get(k)}".encode()) h.update(f"{k}={tech.get(k)}".encode())
h.update(f"shape={roi.shape}".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 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: def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti. """Solleva 422 se il train non ha prodotto varianti.
@@ -303,6 +340,10 @@ class MatchParams(BaseModel):
model_id: str model_id: str
scene_id: str scene_id: str
roi: list[int] # [x, y, w, h] nell'immagine modello 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_min: float = 0.0
angle_max: float = 360.0 angle_max: float = 360.0
angle_step: float = 5.0 angle_step: float = 5.0
@@ -384,6 +425,8 @@ class SimpleMatchParams(BaseModel):
model_id: str model_id: str
scene_id: str scene_id: str
roi: list[int] roi: list[int]
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
roi_poly: list[list[float]] | None = None
tipo: str = "intero" # "intero" | "parziale" tipo: str = "intero" # "intero" | "parziale"
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
scala: str = "fissa" # chiave SCALE_PRESETS 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]) 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") @app.get("/image/{iid}/raw")
def image_raw(iid: str): def image_raw(iid: str):
img = _load_image(iid) img = _load_image(iid)
@@ -617,6 +680,12 @@ def match(p: MatchParams):
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
# 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 = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) 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] roi_img = model[y:y + h, x:x + w]
@@ -630,6 +699,9 @@ def match(p: MatchParams):
"scale_step": p.scale_step, "scale_step": p.scale_step,
"spread_radius": p.spread_radius, "spread_radius": p.spread_radius,
"pyramid_levels": p.pyramid_levels, "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) key = _matcher_cache_key(roi_img, tech_for_cache)
# Lock globale: matcher condivisi tra thread del pool FastAPI # Lock globale: matcher condivisi tra thread del pool FastAPI
@@ -646,7 +718,7 @@ def match(p: MatchParams):
spread_radius=p.spread_radius, spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels, 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) _check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
@@ -689,11 +761,20 @@ def match_simple(p: SimpleMatchParams):
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
# 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 = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) 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] roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, roi_img) 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) key = _matcher_cache_key(roi_img, tech)
# Halcon-mode init params: incidono sul training, includere in cache key # 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_polarity=p.use_polarity,
use_gpu=p.use_gpu, 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) _check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
@@ -778,6 +859,8 @@ class SaveRecipeParams(BaseModel):
model_id: str model_id: str
scene_id: str | None = None scene_id: str | None = None
roi: list[int] roi: list[int]
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
roi_poly: list[list[float]] | None = None
# Riusa stessi param simple per training equivalente # Riusa stessi param simple per training equivalente
tipo: str = "intero" tipo: str = "intero"
simmetria: str = "nessuna" simmetria: str = "nessuna"
@@ -897,6 +980,12 @@ def save_recipe(p: SaveRecipeParams):
model = _load_image(p.model_id) model = _load_image(p.model_id)
if model is None: if model is None:
raise HTTPException(404, "Modello non trovato") raise HTTPException(404, "Modello non trovato")
# 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 = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) 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] roi_img = model[y:y + h, x:x + w]
@@ -925,7 +1014,7 @@ def save_recipe(p: SaveRecipeParams):
) )
# Lock globale: serializza il training pesante col matching in corso # Lock globale: serializza il training pesante col matching in corso
with _MATCHER_LOCK: with _MATCHER_LOCK:
n_var = m.train(roi_img) n_var = m.train(roi_img, train_mask)
_check_trained(m, n_var) _check_trained(m, n_var)
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-") safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name: if not safe_name:
+180 -1
View File
@@ -20,6 +20,10 @@ const state = {
model: null, scene: null, roi: null, drag: null, model: null, scene: null, roi: null, drag: null,
matches: [], annotatedImg: null, matches: [], annotatedImg: null,
active_recipe: null, // V: ricetta caricata (string nome) o null active_recipe: null, // V: ricetta caricata (string nome) o null
// ROI poligonale: vertici [x, y] in coordinate immagine modello
polyMode: false, polyPts: [], polyClosed: false,
// Export JSON: ultimo match completo (params + risposta)
lastMatch: null,
}; };
// ---------- Forms ---------- // ---------- Forms ----------
@@ -148,6 +152,15 @@ async function uploadToFolder(file) {
return await r.json(); return await r.json();
} }
async function uploadDxf(file) {
// DXF: rasterizzato server-side in template grayscale (vedi pm2d/dxf.py)
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/upload_dxf", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
return await r.json();
}
async function refreshPickers() { async function refreshPickers() {
const {files, dir} = await fetchImagesList(); const {files, dir} = await fetchImagesList();
buildThumbPicker("picker-model", files, onSelectModel); buildThumbPicker("picker-model", files, onSelectModel);
@@ -222,6 +235,7 @@ async function onSelectModel(filename) {
const img = await loadImage(`/image/${meta.id}/raw`); const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img }; state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null; state.roi = null;
state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale
document.getElementById("roi-info").textContent = "ROI: (nessuna)"; document.getElementById("roi-info").textContent = "ROI: (nessuna)";
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`); setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
renderModel(); renderModel();
@@ -262,12 +276,36 @@ function renderModel() {
state.model.scale = fit.sc; state.model.scale = fit.sc;
state.model.ox = fit.ox; state.model.oy = fit.oy; state.model.ox = fit.ox; state.model.oy = fit.oy;
ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh); ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh);
if (state.roi) { if (state.roi && !state.polyMode) {
const [x, y, w, h] = state.roi; const [x, y, w, h] = state.roi;
ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2; ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc, ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc,
w * fit.sc, h * fit.sc); w * fit.sc, h * fit.sc);
} }
// ROI poligonale: path aperto giallo, chiuso verde con fill semitrasparente
if (state.polyMode && state.polyPts.length > 0) {
ctx.beginPath();
state.polyPts.forEach(([px, py], i) => {
const cx = fit.ox + px * fit.sc;
const cy = fit.oy + py * fit.sc;
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
if (state.polyClosed) {
ctx.closePath();
ctx.fillStyle = "rgba(0, 255, 128, 0.18)";
ctx.fill();
ctx.strokeStyle = "#00ff80";
} else {
ctx.strokeStyle = "#ffff00";
}
ctx.lineWidth = 2;
ctx.stroke();
// Vertici come quadratini
ctx.fillStyle = state.polyClosed ? "#00ff80" : "#ffff00";
for (const [px, py] of state.polyPts) {
ctx.fillRect(fit.ox + px * fit.sc - 2, fit.oy + py * fit.sc - 2, 4, 4);
}
}
if (state.drag) { if (state.drag) {
ctx.strokeStyle = "#ffff00"; ctx.strokeStyle = "#ffff00";
ctx.setLineDash([4, 2]); ctx.lineWidth = 2; ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
@@ -301,10 +339,35 @@ function setupROI() {
const cnv = document.getElementById("c-model"); const cnv = document.getElementById("c-model");
cnv.addEventListener("mousedown", (e) => { cnv.addEventListener("mousedown", (e) => {
if (!state.model) return; if (!state.model) return;
if (state.polyMode) return; // poly mode: gestito da click/dblclick
const p = canvasPos(cnv, e); const p = canvasPos(cnv, e);
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
renderModel(); renderModel();
}); });
// ROI poligonale: click aggiunge vertice, doppio click chiude
cnv.addEventListener("click", (e) => {
if (!state.model || !state.polyMode || state.polyClosed) return;
const m = state.model;
const p = canvasPos(cnv, e);
const ix = (p.x - m.ox) / m.scale;
const iy = (p.y - m.oy) / m.scale;
if (ix < 0 || iy < 0 || ix > m.w || iy > m.h) return; // fuori immagine
const last = state.polyPts[state.polyPts.length - 1];
// Dedup: il dblclick genera anche 2 click ravvicinati
if (last && Math.hypot(ix - last[0], iy - last[1]) < 3) return;
state.polyPts.push([
Math.max(0, Math.min(Math.round(ix), m.w - 1)),
Math.max(0, Math.min(Math.round(iy), m.h - 1)),
]);
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici (doppio click o "Chiudi" per chiudere)`;
renderModel();
});
cnv.addEventListener("dblclick", (e) => {
if (!state.polyMode) return;
e.preventDefault();
closePoly();
});
cnv.addEventListener("mousemove", (e) => { cnv.addEventListener("mousemove", (e) => {
if (!state.drag) return; if (!state.drag) return;
const p = canvasPos(cnv, e); const p = canvasPos(cnv, e);
@@ -331,6 +394,41 @@ function setupROI() {
}); });
} }
// ---------- ROI poligonale ----------
function closePoly() {
if (!state.polyMode || state.polyClosed) return;
if (state.polyPts.length < 3) {
setStatus("Servono almeno 3 vertici per chiudere il poligono");
return;
}
state.polyClosed = true;
// ROI = bounding box del poligono (il server riceve anche roi_poly)
const xs = state.polyPts.map((p) => p[0]);
const ys = state.polyPts.map((p) => p[1]);
const x0 = Math.min(...xs), y0 = Math.min(...ys);
const w = Math.max(...xs) - x0, h = Math.max(...ys) - y0;
state.roi = [x0, y0, Math.max(1, w), Math.max(1, h)];
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici, bbox ${w}x${h} @ (${x0}, ${y0})`;
renderModel();
}
function resetPoly() {
state.polyPts = [];
state.polyClosed = false;
state.roi = null;
document.getElementById("roi-info").textContent = state.polyMode
? "Poligono: clicca sul modello per aggiungere vertici"
: "ROI: (nessuna)";
renderModel();
}
function getRoiPoly() {
// Poligono valido solo se in modalità poly e chiuso
return (state.polyMode && state.polyClosed && state.polyPts.length >= 3)
? state.polyPts : null;
}
// ---------- Match action ---------- // ---------- Match action ----------
async function doMatchRecipe() { async function doMatchRecipe() {
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
@@ -352,6 +450,12 @@ async function doMatchRecipe() {
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; } if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: "/match_recipe", params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`); `/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene(); renderScene();
@@ -371,7 +475,11 @@ async function doMatch() {
} }
if (!state.model) { setStatus("Carica modello"); return; } if (!state.model) { setStatus("Carica modello"); return; }
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
if (state.polyMode && !state.polyClosed) {
setStatus("Chiudi il poligono (doppio click o bottone Chiudi)"); return;
}
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; } if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
const roiPoly = getRoiPoly();
const user = readUserParams(); const user = readUserParams();
const adv = readAdvancedOverrides(); const adv = readAdvancedOverrides();
setStatus("Match in corso..."); setStatus("Match in corso...");
@@ -397,6 +505,7 @@ async function doMatch() {
const angMax = SYM_MAP[user.simmetria] ?? 360; const angMax = SYM_MAP[user.simmetria] ?? 360;
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
angle_min: 0, angle_max: angMax, angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] ?? 5, angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep, scale_min: smin, scale_max: smax, scale_step: sstep,
@@ -412,6 +521,7 @@ async function doMatch() {
} else { } else {
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
...user, ...user,
}; };
} }
@@ -426,6 +536,12 @@ async function doMatch() {
} }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: url, params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`); `/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene(); renderScene();
@@ -461,6 +577,38 @@ function setStatus(s) {
document.getElementById("status").textContent = s; document.getElementById("status").textContent = s;
} }
// ---------- C: Export JSON risultati ----------
function exportMatchJSON() {
if (!state.lastMatch) {
alert("Nessun match da esportare: esegui prima un MATCH.");
return;
}
const lm = state.lastMatch;
const payload = {
timestamp: new Date().toISOString(),
image_id: lm.image_id,
endpoint: lm.endpoint,
params: lm.params,
matches: lm.response.matches.map((m) => ({
cx: m.cx, cy: m.cy, angle_deg: m.angle_deg,
scale: m.scale, score: m.score, bbox: m.bbox_poly,
})),
train_time: lm.response.train_time,
find_time: lm.response.find_time,
num_variants: lm.response.num_variants,
};
const blob = new Blob([JSON.stringify(payload, null, 2)],
{ type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
a.download = `pm2d_match_${ts}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
// ---------- Init ---------- // ---------- Init ----------
// ---------- Edge preview (clean rumore) ---------- // ---------- Edge preview (clean rumore) ----------
let _epDebounce = null; let _epDebounce = null;
@@ -734,6 +882,7 @@ async function saveRecipe() {
model_id: state.model.id, model_id: state.model.id,
scene_id: state.scene?.id || state.model.id, scene_id: state.scene?.id || state.model.id,
roi: state.roi, roi: state.roi,
roi_poly: getRoiPoly(),
tipo: user.tipo, tipo: user.tipo,
simmetria: user.simmetria, simmetria: user.simmetria,
scala: user.scala, scala: user.scala,
@@ -777,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => {
upEl.addEventListener("change", async (e) => { upEl.addEventListener("change", async (e) => {
const f = e.target.files[0]; const f = e.target.files[0];
if (!f) return; if (!f) return;
// A: file DXF → rasterizza server-side e usa direttamente come modello
if (f.name.toLowerCase().endsWith(".dxf")) {
setStatus(`Rasterizzazione DXF ${f.name}...`);
try {
const meta = await uploadDxf(f);
const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null;
resetPoly();
setStatus(`DXF ${f.name} rasterizzato ` +
`${meta.width}x${meta.height} — disegna ROI sul modello`);
renderModel();
} catch (err) {
setStatus(`Errore DXF: ${err.message}`);
}
e.target.value = "";
return;
}
setStatus(`Caricamento ${f.name} nella cartella...`); setStatus(`Caricamento ${f.name} nella cartella...`);
try { try {
const res = await uploadToFolder(f); const res = await uploadToFolder(f);
@@ -789,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => {
}); });
document.getElementById("btn-match").addEventListener("click", doMatch); document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune); document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
// B: ROI poligonale (toggle + chiudi + reset)
document.getElementById("roi-poly-toggle").addEventListener("change", (e) => {
state.polyMode = e.target.checked;
document.getElementById("btn-poly-close").disabled = !state.polyMode;
document.getElementById("btn-poly-reset").disabled = !state.polyMode;
resetPoly();
});
document.getElementById("btn-poly-close").addEventListener("click", closePoly);
document.getElementById("btn-poly-reset").addEventListener("click", resetPoly);
// C: export JSON ultimo match
document.getElementById("btn-export-json").addEventListener("click",
exportMatchJSON);
document.getElementById("btn-save-recipe").addEventListener("click", document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe); saveRecipe);
document.getElementById("btn-load-recipe").addEventListener("click", document.getElementById("btn-load-recipe").addEventListener("click",
+16 -2
View File
@@ -30,9 +30,9 @@
title="Analizza ROI e derivata parametri ottimali (Halcon-style)"> title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune ⚙ Auto-tune
</button> </button>
<label class="btn" title="Carica nuovo file nella cartella immagini"> <label class="btn" title="Carica nuovo file nella cartella immagini (immagine o DXF)">
⬆ Carica file ⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden> <input type="file" id="file-upload" accept="image/*,.dxf" hidden>
</label> </label>
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span> <span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
</div> </div>
@@ -45,6 +45,15 @@
<canvas id="c-model" width="380" height="420"></canvas> <canvas id="c-model" width="380" height="420"></canvas>
</div> </div>
<div id="roi-info">ROI: (nessuna)</div> <div id="roi-info">ROI: (nessuna)</div>
<div id="roi-poly-bar" style="display:flex; gap:6px; align-items:center; margin-top:6px">
<label style="display:flex; gap:4px; align-items:center; font-size:12px; cursor:pointer">
<input type="checkbox" id="roi-poly-toggle"> ROI poligonale
</label>
<button class="btn" id="btn-poly-close" type="button" disabled
title="Chiude il poligono (equivale al doppio click)">Chiudi</button>
<button class="btn" id="btn-poly-reset" type="button" disabled
title="Cancella i vertici del poligono">Reset</button>
</div>
<details id="edge-preview-panel" style="margin-top:10px"> <details id="edge-preview-panel" style="margin-top:10px">
<summary>🔬 Anteprima edge / pulizia rumore</summary> <summary>🔬 Anteprima edge / pulizia rumore</summary>
<div style="font-size:11px; color:#aaa; margin:4px 0"> <div style="font-size:11px; color:#aaa; margin:4px 0">
@@ -248,6 +257,11 @@
<div class="kv"><span>find:</span><span id="t-find">-</span></div> <div class="kv"><span>find:</span><span id="t-find">-</span></div>
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div> <div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
<div class="kv"><span>match:</span><span id="t-match">-</span></div> <div class="kv"><span>match:</span><span id="t-match">-</span></div>
<button class="btn" id="btn-export-json" type="button" disabled
style="margin-top:8px; width:100%"
title="Scarica i risultati dell'ultimo match in formato JSON">
⬇ Esporta JSON
</button>
<details id="diag-panel" style="margin-top:10px"> <details id="diag-panel" style="margin-top:10px">
<summary>🔍 Diagnostica (CC)</summary> <summary>🔍 Diagnostica (CC)</summary>