diff --git a/pm2d/web/server.py b/pm2d/web/server.py index 4a29468..ff02660 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -631,6 +631,107 @@ class SaveRecipeParams(BaseModel): name: str # nome file ricetta (no path) +class EdgePreviewParams(BaseModel): + model_id: str + roi: list[int] + weak_grad: float = 30.0 + strong_grad: float = 60.0 + num_features: int = 96 + min_feature_spacing: int = 3 + use_polarity: bool = False + + +@app.post("/preview_edges") +def preview_edges(p: EdgePreviewParams): + """Estrae edge feature dalla ROI con i parametri dati e ritorna + immagine annotata con i pixel selezionati come overlay. + + Permette tuning interattivo delle soglie weak/strong_grad e + num_features per "togliere le sporcizie" (rumore di sfondo, + edge spuri) prima di trainare il matcher vero. + """ + model = _load_image(p.model_id) + if model is None: + raise HTTPException(404, "Modello non trovato") + x, y, w, h = p.roi + H_m, W_m = model.shape[:2] + x = max(0, min(int(x), W_m - 1)); y = max(0, min(int(y), H_m - 1)) + w = max(1, min(int(w), W_m - x)); h = max(1, min(int(h), H_m - y)) + roi_img = model[y:y + h, x:x + w] + # Matcher temporaneo solo per estrazione feature (no train completo) + m = LineShapeMatcher( + weak_grad=p.weak_grad, + strong_grad=p.strong_grad, + num_features=p.num_features, + min_feature_spacing=p.min_feature_spacing, + use_polarity=p.use_polarity, + ) + gray = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) if roi_img.ndim == 3 else roi_img + mag, bins = m._gradient(gray) + fx, fy, fb = m._extract_features(mag, bins, None) + # Mostra anche i pixel "weak/strong" come heatmap di sfondo + out = roi_img.copy() if roi_img.ndim == 3 else cv2.cvtColor(roi_img, cv2.COLOR_GRAY2BGR) + # Overlay magnitude leggera + mag_norm = np.clip(mag / max(1.0, mag.max()) * 255, 0, 255).astype(np.uint8) + mag_color = cv2.applyColorMap(mag_norm, cv2.COLORMAP_BONE) + out = cv2.addWeighted(out, 0.6, mag_color, 0.4, 0) + # Pixel "strong" con hysteresis: contorno verde scuro tenue + if m.weak_grad < m.strong_grad: + edge_mask = m._hysteresis_mask(mag).astype(np.uint8) * 255 + else: + edge_mask = (mag >= m.strong_grad).astype(np.uint8) * 255 + edge_overlay = np.zeros_like(out) + edge_overlay[edge_mask > 0] = (0, 80, 0) # verde scuro + out = cv2.addWeighted(out, 1.0, edge_overlay, 0.5, 0) + # Feature scelte: cerchietti colorati per bin + bin_colors = [ + (255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0), + (0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255), + (255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100), + (100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200), + ] + for i in range(len(fx)): + b = int(fb[i]) + col = bin_colors[b % len(bin_colors)] + cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA) + # UCS sul baricentro feature (richiesta utente): assi X rosso, Y verde + bary_cx = bary_cy = None + if len(fx) > 0: + bary_cx = float(np.mean(fx)) + bary_cy = float(np.mean(fy)) + bx, by = int(round(bary_cx)), int(round(bary_cy)) + axis_len = max(20, int(0.15 * max(out.shape[:2]))) + # X axis (rosso, verso destra) + cv2.arrowedLine(out, (bx, by), (bx + axis_len, by), + (0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2) + cv2.putText(out, "X", (bx + axis_len + 4, by + 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA) + # Y axis (verde, verso il basso = convenzione image y-down) + cv2.arrowedLine(out, (bx, by), (bx, by + axis_len), + (0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2) + cv2.putText(out, "Y", (bx + 4, by + axis_len + 12), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA) + # Origine: cerchio bianco con bordo nero + cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA) + cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA) + img_id = _store_image(out) + n_edge_strong = int((mag >= m.strong_grad).sum()) + n_edge_total = int(edge_mask.sum() / 255) + return { + "preview_id": img_id, + "n_features": len(fx), + "n_edge_strong": n_edge_strong, + "n_edge_after_hysteresis": n_edge_total, + "mag_max": float(mag.max()), + "mag_p50": float(np.percentile(mag, 50)), + "mag_p85": float(np.percentile(mag, 85)), + "ucs_baricentro": ( + {"cx": round(bary_cx, 2), "cy": round(bary_cy, 2)} + if bary_cx is not None else None + ), + } + + @app.post("/recipes") def save_recipe(p: SaveRecipeParams): """Allena matcher e salva su disco come ricetta riutilizzabile.""" diff --git a/pm2d/web/static/app.js b/pm2d/web/static/app.js index 49c76d9..02e00fa 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -438,6 +438,109 @@ function setStatus(s) { } // ---------- Init ---------- +// ---------- Edge preview (clean rumore) ---------- +let _epDebounce = null; +let _epLastImg = null; + +async function fetchEdgePreview() { + if (!state.model || !state.roi) { + document.getElementById("edge-preview-info").textContent = + "Disegna prima la ROI sul modello"; + return; + } + const body = { + model_id: state.model.id, + roi: state.roi, + weak_grad: parseFloat(document.getElementById("ep-weak").value), + strong_grad: parseFloat(document.getElementById("ep-strong").value), + num_features: parseInt(document.getElementById("ep-nf").value, 10), + min_feature_spacing: parseInt(document.getElementById("ep-sp").value, 10), + use_polarity: document.getElementById("ep-pol").checked, + }; + try { + const r = await fetch("/preview_edges", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + _epLastImg = await loadImage(`/image/${j.preview_id}/raw?t=${Date.now()}`); + drawEdgePreview(); + const ucs = j.ucs_baricentro + ? ` | UCS=(${j.ucs_baricentro.cx},${j.ucs_baricentro.cy})` + : ""; + document.getElementById("edge-preview-info").innerHTML = + `${j.n_features} feature scelte (di ${j.n_edge_after_hysteresis} edge totali)
` + + `mag: max=${j.mag_max.toFixed(0)} p50=${j.mag_p50.toFixed(0)} ` + + `p85=${j.mag_p85.toFixed(0)}${ucs}`; + } catch (e) { + document.getElementById("edge-preview-info").textContent = + `Errore preview: ${e.message}`; + } +} + +function drawEdgePreview() { + const cnv = document.getElementById("c-edge-preview"); + if (!_epLastImg) return; + const ctx = cnv.getContext("2d"); + // Fit-contain + const r = Math.min(cnv.width / _epLastImg.width, + cnv.height / _epLastImg.height); + const w = _epLastImg.width * r; + const h = _epLastImg.height * r; + const ox = (cnv.width - w) / 2; + const oy = (cnv.height - h) / 2; + ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cnv.width, cnv.height); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(_epLastImg, ox, oy, w, h); +} + +function scheduleEdgePreview() { + if (_epDebounce) clearTimeout(_epDebounce); + _epDebounce = setTimeout(fetchEdgePreview, 200); +} + +function bindEdgePreviewControls() { + const slid = (id, valEl) => { + const el = document.getElementById(id); + const v = document.getElementById(valEl); + el.addEventListener("input", () => { + v.textContent = el.value; + scheduleEdgePreview(); + }); + }; + slid("ep-weak", "ep-weak-v"); + slid("ep-strong", "ep-strong-v"); + slid("ep-nf", "ep-nf-v"); + slid("ep-sp", "ep-sp-v"); + document.getElementById("ep-pol").addEventListener("change", + scheduleEdgePreview); + // Auto-refresh quando il pannello viene aperto + document.getElementById("edge-preview-panel").addEventListener("toggle", + (e) => { if (e.target.open) fetchEdgePreview(); }); + document.getElementById("btn-edge-apply").addEventListener("click", () => { + // Copia i valori correnti nei campi avanzati + const map = { + "ep-weak": "adv-weak_grad", + "ep-strong": "adv-strong_grad", + "ep-nf": "adv-num_features", + "ep-sp": "adv-min_feature_spacing", + }; + for (const [src, dst] of Object.entries(map)) { + const dstEl = document.getElementById(dst); + if (dstEl) dstEl.value = document.getElementById(src).value; + } + // use_polarity: alla checkbox della modalita Halcon + const polCb = document.getElementById("hc-use-polarity"); + if (polCb) polCb.checked = document.getElementById("ep-pol").checked; + // Apri pannello Avanzate per feedback + const advDetails = document.querySelectorAll("#col-params details"); + advDetails.forEach((d) => { d.open = true; }); + alert("Parametri edge applicati. Esegui MATCH per usare i valori scelti."); + }); +} + // ---------- CC: Diagnostica match ---------- function renderDiag(diag, n_matches) { const el = document.getElementById("diag-content"); @@ -665,6 +768,7 @@ window.addEventListener("DOMContentLoaded", async () => { document.getElementById("btn-unload-recipe").addEventListener("click", unloadRecipe); refreshRecipeList(); + bindEdgePreviewControls(); const slider = document.getElementById("p-min-score"); slider.addEventListener("input", (e) => { document.getElementById("v-score").textContent = diff --git a/pm2d/web/static/index.html b/pm2d/web/static/index.html index 4d014ef..442a9b3 100644 --- a/pm2d/web/static/index.html +++ b/pm2d/web/static/index.html @@ -45,6 +45,40 @@
ROI: (nessuna)
+
+ 🔬 Anteprima edge / pulizia rumore +
+ Regola le soglie per togliere edge spuri (sporcizie). UCS rosso/verde + sul baricentro feature. +
+
+ + + + + + +
+
+ +
+
+ Disegna ROI e apri questo pannello per generare anteprima +
+
diff --git a/pm2d/web/static/style.css b/pm2d/web/static/style.css index e13d55b..d93c7a6 100644 --- a/pm2d/web/static/style.css +++ b/pm2d/web/static/style.css @@ -173,3 +173,18 @@ footer h2 { } .hc-row.hc-num label { font-size: 11px; color: #aaa; } .hc-row.hc-num input { width: 100%; } + +/* Edge preview panel */ +.ep-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; + margin-top: 6px; + font-size: 12px; +} +.ep-row { + display: flex; flex-direction: column; gap: 2px; + font-size: 11px; color: #aaa; +} +.ep-row input[type="range"] { width: 100%; } +.ep-row span { color: #fff; font-weight: bold; font-family: monospace; }