diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2e2901a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/pm2d/dxf.py b/pm2d/dxf.py new file mode 100644 index 0000000..9e4f93a --- /dev/null +++ b/pm2d/dxf.py @@ -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 diff --git a/pm2d/web/server.py b/pm2d/web/server.py index 7a77872..df68a60 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -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: diff --git a/pm2d/web/static/app.js b/pm2d/web/static/app.js index 540989e..20aee0f 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -20,6 +20,10 @@ const state = { model: null, scene: null, roi: null, drag: null, matches: [], annotatedImg: 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 ---------- @@ -148,6 +152,15 @@ async function uploadToFolder(file) { 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() { const {files, dir} = await fetchImagesList(); buildThumbPicker("picker-model", files, onSelectModel); @@ -222,6 +235,7 @@ async function onSelectModel(filename) { const img = await loadImage(`/image/${meta.id}/raw`); state.model = { id: meta.id, w: meta.width, h: meta.height, img }; state.roi = null; + state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale document.getElementById("roi-info").textContent = "ROI: (nessuna)"; setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`); renderModel(); @@ -262,12 +276,36 @@ function renderModel() { state.model.scale = fit.sc; state.model.ox = fit.ox; state.model.oy = fit.oy; 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; ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2; ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * 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) { ctx.strokeStyle = "#ffff00"; ctx.setLineDash([4, 2]); ctx.lineWidth = 2; @@ -301,10 +339,35 @@ function setupROI() { const cnv = document.getElementById("c-model"); cnv.addEventListener("mousedown", (e) => { if (!state.model) return; + if (state.polyMode) return; // poly mode: gestito da click/dblclick const p = canvasPos(cnv, e); state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; 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) => { if (!state.drag) return; 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 ---------- async function doMatchRecipe() { if (!state.scene) { setStatus("Carica scena"); return; } @@ -352,6 +450,12 @@ async function doMatchRecipe() { if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; } const data = await r.json(); 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( `/image/${data.annotated_id}/raw?t=${Date.now()}`); renderScene(); @@ -371,7 +475,11 @@ async function doMatch() { } if (!state.model) { setStatus("Carica modello"); 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; } + const roiPoly = getRoiPoly(); const user = readUserParams(); const adv = readAdvancedOverrides(); setStatus("Match in corso..."); @@ -397,6 +505,7 @@ async function doMatch() { const angMax = SYM_MAP[user.simmetria] ?? 360; body = { model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, + roi_poly: roiPoly, angle_min: 0, angle_max: angMax, angle_step: PREC_MAP[user.precisione] ?? 5, scale_min: smin, scale_max: smax, scale_step: sstep, @@ -412,6 +521,7 @@ async function doMatch() { } else { body = { model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, + roi_poly: roiPoly, ...user, }; } @@ -426,6 +536,12 @@ async function doMatch() { } const data = await r.json(); 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( `/image/${data.annotated_id}/raw?t=${Date.now()}`); renderScene(); @@ -461,6 +577,38 @@ function setStatus(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 ---------- // ---------- Edge preview (clean rumore) ---------- let _epDebounce = null; @@ -734,6 +882,7 @@ async function saveRecipe() { model_id: state.model.id, scene_id: state.scene?.id || state.model.id, roi: state.roi, + roi_poly: getRoiPoly(), tipo: user.tipo, simmetria: user.simmetria, scala: user.scala, @@ -777,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => { upEl.addEventListener("change", async (e) => { const f = e.target.files[0]; 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...`); try { const res = await uploadToFolder(f); @@ -789,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => { }); document.getElementById("btn-match").addEventListener("click", doMatch); 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", saveRecipe); document.getElementById("btn-load-recipe").addEventListener("click", diff --git a/pm2d/web/static/index.html b/pm2d/web/static/index.html index 4d23bc6..db5f360 100644 --- a/pm2d/web/static/index.html +++ b/pm2d/web/static/index.html @@ -30,9 +30,9 @@ title="Analizza ROI e derivata parametri ottimali (Halcon-style)"> ⚙ Auto-tune -