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 @@