From d35bb574ef1cf2be129aa9675efb5fce170b22c5 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 24 Apr 2026 09:56:34 +0200 Subject: [PATCH] ui: parametri user-friendly (tipo/simmetria/scala/precisione) Nascosti i parametri tecnici (num_features, weak/strong_grad, spread, pyramid) incomprensibili per operatori. Sostituiti da scelte semantiche: - Tipo modello: intero | parziale - Simmetria: nessuna | bilaterale (180) | rotazionale 3/4/6/8x - Variazione scala: fissa | 10% | 25% | 50% - Precisione: veloce 10 | normale 5 | preciso 2 - Score minimo: slider - Max match: input Server: nuovo endpoint POST /match_simple. Deriva tecnici via _simple_to_technical(roi) che analizza la ROI: - weak/strong_grad da percentili - num_features da densita edge x tipo - pyramid_levels da min(h,w) ROI - spread_radius proporzionale Frontend: select + slider, sezione Avanzate collassabile per override. Test rings_and_nuts preset intero/nessuna/medio/normale: 3/3 ruote in 2.14s Co-Authored-By: Claude Opus 4.7 (1M context) --- pm2d/web/server.py | 140 ++++++++++++++++++++++++++ pm2d/web/static/app.js | 194 ++++++++++++++++++------------------- pm2d/web/static/index.html | 63 +++++++++++- pm2d/web/static/style.css | 37 +++++-- 4 files changed, 322 insertions(+), 112 deletions(-) diff --git a/pm2d/web/server.py b/pm2d/web/server.py index b155f48..749933d 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -146,6 +146,94 @@ class TuneParams(BaseModel): roi: list[int] +# ---------- User-facing (simple) params ---------- + +SYMMETRY_TO_ANGLE_MAX = { + "nessuna": 360.0, + "bilaterale": 180.0, + "rot_3": 120.0, + "rot_4": 90.0, + "rot_6": 60.0, + "rot_8": 45.0, +} + +SCALE_PRESETS = { + "fissa": (1.0, 1.0, 0.1), + "mini": (0.9, 1.1, 0.05), # ±10% + "medio": (0.75, 1.25, 0.05), # ±25% + "max": (0.5, 1.5, 0.05), # ±50% +} + +PRECISION_ANGLE_STEP = { + "veloce": 10.0, + "normale": 5.0, + "preciso": 2.0, +} + + +class SimpleMatchParams(BaseModel): + model_id: str + scene_id: str + roi: list[int] + tipo: str = "intero" # "intero" | "parziale" + simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX + scala: str = "fissa" # chiave SCALE_PRESETS + precisione: str = "normale" # chiave PRECISION_ANGLE_STEP + min_score: float = 0.70 + max_matches: int = 25 + + +def _simple_to_technical( + p: SimpleMatchParams, roi_img: np.ndarray, +) -> dict: + """Converti parametri user-facing → tecnici usando analisi della ROI.""" + from pm2d.auto_tune import auto_tune as _auto + + tune = _auto(roi_img) + h, w = roi_img.shape[:2] + min_side = min(h, w) + + # Feature count: parziale = meno feature (area minore) + nf = tune["num_features"] + if p.tipo == "parziale": + nf = max(32, int(nf * 0.6)) + + # Piramide derivata da dimensione ROI + if min_side < 60: + pyr = 1 + elif min_side < 150: + pyr = 2 + elif min_side < 400: + pyr = 3 + else: + pyr = 4 + + # Spread radius ~2-3% del lato minimo + spread = max(3, min(10, int(round(min_side * 0.03)))) + + angle_max = SYMMETRY_TO_ANGLE_MAX.get(p.simmetria, 360.0) + smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1)) + ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0) + + return { + "num_features": nf, + "weak_grad": tune["weak_grad"], + "strong_grad": tune["strong_grad"], + "spread_radius": spread, + "pyramid_levels": pyr, + "angle_min": 0.0, + "angle_max": angle_max, + "angle_step": ang_step, + "scale_min": smin, + "scale_max": smax, + "scale_step": sstep, + "min_score": p.min_score, + "max_matches": p.max_matches, + "nms_radius": 0, + "verify_threshold": 0.4, + } + + # ---------------- Endpoints ---------------- @app.get("/", response_class=HTMLResponse) @@ -220,6 +308,58 @@ def match(p: MatchParams): ) +@app.post("/match_simple", response_model=MatchResp) +def match_simple(p: SimpleMatchParams): + """Match con parametri user-facing (tipo/simmetria/scala/precisione). + + Il server deriva i parametri tecnici (num_features, soglie gradiente, + piramide, ecc.) dall'analisi automatica della ROI. + """ + model = _IMAGES.get(p.model_id) + scene = _IMAGES.get(p.scene_id) + if model is None or scene is None: + raise HTTPException(404, "Immagini non trovate") + x, y, w, h = p.roi + x = max(0, x); y = max(0, y) + w = max(1, min(w, model.shape[1] - x)) + h = max(1, min(h, model.shape[0] - y)) + roi_img = model[y:y + h, x:x + w] + + tech = _simple_to_technical(p, roi_img) + + m = LineShapeMatcher( + num_features=tech["num_features"], + weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], + angle_range_deg=(tech["angle_min"], tech["angle_max"]), + angle_step_deg=tech["angle_step"], + scale_range=(tech["scale_min"], tech["scale_max"]), + scale_step=tech["scale_step"], + spread_radius=tech["spread_radius"], + pyramid_levels=tech["pyramid_levels"], + ) + t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 + nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None + t0 = time.time() + matches = m.find( + scene, min_score=tech["min_score"], max_matches=tech["max_matches"], + nms_radius=nms, verify_threshold=tech["verify_threshold"], + ) + t_find = time.time() - t0 + + tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) + annotated = _draw_matches(scene, matches, tg) + ann_id = _store_image(annotated) + + return MatchResp( + matches=[MatchResult( + cx=mt.cx, cy=mt.cy, angle_deg=mt.angle_deg, scale=mt.scale, + score=mt.score, bbox_poly=mt.bbox_poly.tolist(), + ) for mt in matches], + train_time=t_train, find_time=t_find, + num_variants=n, annotated_id=ann_id, + ) + + @app.post("/auto_tune") def tune(p: TuneParams): model = _IMAGES.get(p.model_id) diff --git a/pm2d/web/static/app.js b/pm2d/web/static/app.js index 3ba6000..bf9865b 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -1,21 +1,14 @@ -// Pattern Matching 2D - frontend +// Pattern Matching 2D - frontend (UI semplificata operator-friendly) -const PARAMS = [ - ["angle_min", "Angolo min [°]", "float", 0], - ["angle_max", "Angolo max [°]", "float", 360], - ["angle_step", "Angolo step [°]", "float", 5], - ["scale_min", "Scala min", "float", 1], - ["scale_max", "Scala max", "float", 1], - ["scale_step", "Scala step", "float", 0.1], - ["min_score", "Score min [0..1]", "float", 0.55], - ["max_matches", "Max match", "int", 25], - ["nms_radius", "NMS radius (0=auto)", "int", 0], - ["num_features", "Num feature", "int", 96], - ["weak_grad", "Weak grad", "float", 30], - ["strong_grad", "Strong grad", "float", 60], - ["spread_radius", "Spread radius", "int", 5], - ["pyramid_levels", "Pyramid levels", "int", 3], +// Parametri avanzati (sezione collassabile) +const ADV_PARAMS = [ + ["num_features", "Num feature", "int", ""], + ["weak_grad", "Weak grad", "float", ""], + ["strong_grad", "Strong grad", "float", ""], + ["spread_radius", "Spread radius", "int", ""], + ["pyramid_levels", "Pyramid levels", "int", ""], ["verify_threshold", "Verify NCC thr", "float", 0.4], + ["nms_radius", "NMS radius (0=auto)", "int", 0], ]; const PALETTE = [ @@ -24,27 +17,22 @@ const PALETTE = [ ]; const state = { - model: null, // {id, w, h, img:HTMLImageElement, scale, ox, oy} - scene: null, - roi: null, // [x, y, w, h] in originale - drag: null, // {x0,y0,x1,y1} in canvas coords - matches: [], - annotatedImg: null, // HTMLImageElement annotated + model: null, scene: null, roi: null, drag: null, + matches: [], annotatedImg: null, }; -// ---------- Params form ---------- -function buildForm() { - const form = document.getElementById("params-form"); +// ---------- Forms ---------- +function buildAdvancedForm() { + const form = document.getElementById("adv-form"); form.innerHTML = ""; - for (const [key, label, type, def] of PARAMS) { + for (const [key, label, , def] of ADV_PARAMS) { const lbl = document.createElement("label"); lbl.textContent = label; - lbl.htmlFor = `p-${key}`; const inp = document.createElement("input"); - inp.id = `p-${key}`; - inp.name = key; - inp.value = String(def); + inp.id = `adv-${key}`; inp.type = "text"; + inp.placeholder = "auto"; + inp.value = def === "" ? "" : String(def); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") doMatch(); }); @@ -53,23 +41,27 @@ function buildForm() { } } -function readParams() { +function readUserParams() { + return { + tipo: document.getElementById("p-tipo").value, + simmetria: document.getElementById("p-simmetria").value, + scala: document.getElementById("p-scala").value, + precisione: document.getElementById("p-precisione").value, + min_score: parseFloat(document.getElementById("p-min-score").value), + max_matches: parseInt(document.getElementById("p-max-matches").value, 10), + }; +} + +function readAdvancedOverrides() { const out = {}; - for (const [key, , type] of PARAMS) { - const v = document.getElementById(`p-${key}`).value.trim(); + for (const [key, , type] of ADV_PARAMS) { + const v = document.getElementById(`adv-${key}`).value.trim(); + if (v === "") continue; out[key] = type === "int" ? parseInt(v, 10) : parseFloat(v); } return out; } -function setParams(values) { - for (const [key] of PARAMS) { - if (values[key] !== undefined) { - document.getElementById(`p-${key}`).value = String(values[key]); - } - } -} - // ---------- Upload ---------- async function uploadFile(file) { const fd = new FormData(); @@ -103,8 +95,7 @@ async function onLoadScene(file) { const meta = await uploadFile(file); const img = await loadImage(`/image/${meta.id}/raw`); state.scene = { id: meta.id, w: meta.width, h: meta.height, img }; - state.matches = []; - state.annotatedImg = null; + state.matches = []; state.annotatedImg = null; setStatus(`Scena: ${file.name} ${meta.width}x${meta.height}`); renderScene(); } @@ -112,11 +103,8 @@ async function onLoadScene(file) { // ---------- Rendering ---------- function fitToCanvas(img, cw, ch) { const sc = Math.min(cw / img.width, ch / img.height); - const dw = img.width * sc; - const dh = img.height * sc; - const ox = (cw - dw) / 2; - const oy = (ch - dh) / 2; - return { sc, ox, oy, dw, dh }; + const dw = img.width * sc, dh = img.height * sc; + return { sc, ox: (cw - dw) / 2, oy: (ch - dh) / 2, dw, dh }; } function renderModel() { @@ -127,26 +115,22 @@ function renderModel() { if (!state.model) return; const fit = fitToCanvas(state.model.img, cnv.width, cnv.height); 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); if (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, w * fit.sc, h * fit.sc); } if (state.drag) { ctx.strokeStyle = "#ffff00"; - ctx.setLineDash([4, 2]); - ctx.lineWidth = 2; + ctx.setLineDash([4, 2]); ctx.lineWidth = 2; ctx.strokeRect( Math.min(state.drag.x0, state.drag.x1), Math.min(state.drag.y0, state.drag.y1), Math.abs(state.drag.x1 - state.drag.x0), - Math.abs(state.drag.y1 - state.drag.y0), - ); + Math.abs(state.drag.y1 - state.drag.y0)); ctx.setLineDash([]); } } @@ -156,13 +140,10 @@ function renderScene() { const ctx = cnv.getContext("2d"); ctx.fillStyle = "#141414"; ctx.fillRect(0, 0, cnv.width, cnv.height); - if (state.annotatedImg) { - const fit = fitToCanvas(state.annotatedImg, cnv.width, cnv.height); - ctx.drawImage(state.annotatedImg, fit.ox, fit.oy, fit.dw, fit.dh); - } else if (state.scene) { - const fit = fitToCanvas(state.scene.img, cnv.width, cnv.height); - ctx.drawImage(state.scene.img, fit.ox, fit.oy, fit.dw, fit.dh); - } + const img = state.annotatedImg || (state.scene && state.scene.img); + if (!img) return; + const fit = fitToCanvas(img, cnv.width, cnv.height); + ctx.drawImage(img, fit.ox, fit.oy, fit.dw, fit.dh); } // ---------- ROI drag ---------- @@ -185,12 +166,10 @@ function setupROI() { state.drag.x1 = p.x; state.drag.y1 = p.y; renderModel(); }); - cnv.addEventListener("mouseup", (e) => { + cnv.addEventListener("mouseup", () => { if (!state.drag || !state.model) return; - const d = state.drag; - state.drag = null; + const d = state.drag; state.drag = null; if (Math.abs(d.x1 - d.x0) < 4 || Math.abs(d.y1 - d.y0) < 4) return; - // Canvas → originale const m = state.model; const ix0 = Math.round((Math.min(d.x0, d.x1) - m.ox) / m.scale); const iy0 = Math.round((Math.min(d.y0, d.y1) - m.oy) / m.scale); @@ -207,57 +186,68 @@ function setupROI() { }); } -// ---------- Actions ---------- +// ---------- Match action ---------- async function doMatch() { if (!state.model) { setStatus("Carica modello"); return; } if (!state.scene) { setStatus("Carica scena"); return; } if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; } - const params = readParams(); + const user = readUserParams(); + const adv = readAdvancedOverrides(); setStatus("Match in corso..."); - const body = { - model_id: state.model.id, - scene_id: state.scene.id, - roi: state.roi, - ...params, - }; - const r = await fetch("/match", { + + // Se utente ha fornito override avanzati → usa /match (tecnico) + // altrimenti /match_simple (operator mode) + const hasAdv = Object.keys(adv).length > 0; + const url = hasAdv ? "/match" : "/match_simple"; + let body; + if (hasAdv) { + // Merge simple → tecnici base, poi overrides + const SYM_MAP = {nessuna:360, bilaterale:180, rot_3:120, + rot_4:90, rot_6:60, rot_8:45}; + const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05], + medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]}; + const PREC_MAP = {veloce:10, normale:5, preciso:2}; + const [smin, smax, sstep] = SCALE_MAP[user.scala]; + body = { + model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, + angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360, + angle_step: PREC_MAP[user.precisione] || 5, + scale_min: smin, scale_max: smax, scale_step: sstep, + min_score: user.min_score, max_matches: user.max_matches, + num_features: adv.num_features ?? 96, + weak_grad: adv.weak_grad ?? 30, + strong_grad: adv.strong_grad ?? 60, + spread_radius: adv.spread_radius ?? 5, + pyramid_levels: adv.pyramid_levels ?? 3, + verify_threshold: adv.verify_threshold ?? 0.4, + nms_radius: adv.nms_radius ?? 0, + }; + } else { + body = { + model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, + ...user, + }; + } + + const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) { - const t = await r.text(); - setStatus(`Errore: ${t}`); - return; + setStatus(`Errore: ${await r.text()}`); return; } const data = await r.json(); state.matches = data.matches; state.annotatedImg = await loadImage( - `/image/${data.annotated_id}/raw?t=${Date.now()}`, - ); + `/image/${data.annotated_id}/raw?t=${Date.now()}`); renderScene(); renderLegend(); document.getElementById("t-train").textContent = `${data.train_time.toFixed(2)}s`; document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`; document.getElementById("t-var").textContent = data.num_variants; document.getElementById("t-match").textContent = data.matches.length; - setStatus(`${data.matches.length} match trovati`); -} - -async function doAutoTune() { - if (!state.model || !state.roi) { - setStatus("Carica modello + seleziona ROI"); - return; - } - const r = await fetch("/auto_tune", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model_id: state.model.id, roi: state.roi }), - }); - if (!r.ok) { setStatus("Errore auto-tune"); return; } - const tune = await r.json(); - setParams(tune); - setStatus("Auto-tune applicato — premi MATCH"); + setStatus(`${data.matches.length} match trovati${hasAdv ? " (avanzato)" : ""}`); } function renderLegend() { @@ -285,7 +275,7 @@ function setStatus(s) { // ---------- Init ---------- window.addEventListener("DOMContentLoaded", () => { - buildForm(); + buildAdvancedForm(); setupROI(); document.getElementById("file-model").addEventListener("change", (e) => { if (e.target.files[0]) onLoadModel(e.target.files[0]); @@ -294,7 +284,11 @@ window.addEventListener("DOMContentLoaded", () => { if (e.target.files[0]) onLoadScene(e.target.files[0]); }); document.getElementById("btn-match").addEventListener("click", doMatch); - document.getElementById("btn-tune").addEventListener("click", doAutoTune); + const slider = document.getElementById("p-min-score"); + slider.addEventListener("input", (e) => { + document.getElementById("v-score").textContent = + parseFloat(e.target.value).toFixed(2); + }); renderModel(); renderScene(); }); diff --git a/pm2d/web/static/index.html b/pm2d/web/static/index.html index 9bbc355..9ab5ae5 100644 --- a/pm2d/web/static/index.html +++ b/pm2d/web/static/index.html @@ -15,9 +15,8 @@ - - Carica modello + disegna ROI + carica scena + Carica modello, disegna ROI, carica scena @@ -38,9 +37,63 @@
-

PARAMETRI

-
-

TEMPI

+

IMPOSTAZIONI

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Avanzate +
+
+ +

TEMPI

train:-
find:-
varianti:-
diff --git a/pm2d/web/static/style.css b/pm2d/web/static/style.css index d885d28..b767ab3 100644 --- a/pm2d/web/static/style.css +++ b/pm2d/web/static/style.css @@ -56,18 +56,41 @@ canvas { margin-top: 6px; font-size: 12px; color: #aaa; } -#params-form { - display: grid; grid-template-columns: 1fr 100px; gap: 4px 8px; +.field { + margin-bottom: 10px; } -#params-form label { - font-size: 12px; display: flex; align-items: center; +.field label { + display: block; font-size: 12px; color: #b0b0b0; + margin-bottom: 3px; } -#params-form input { +.field select, .field input { + width: 100%; background: #2a2a2a; color: #dcdcdc; + border: 1px solid #444; padding: 5px 6px; border-radius: 3px; + font-size: 12px; +} +.field input[type="range"] { + padding: 0; height: 26px; +} +.field select:focus, .field input:focus { + outline: 1px solid #00c8ff; +} + +#v-score { color: #00c8ff; font-weight: bold; } + +details { margin-top: 10px; font-size: 12px; } +details summary { + cursor: pointer; padding: 4px 0; color: #00c8ff; +} +#adv-form { + display: grid; grid-template-columns: 1fr 80px; gap: 3px 6px; + margin-top: 4px; +} +#adv-form label { font-size: 11px; color: #999; } +#adv-form input { background: #2a2a2a; color: #dcdcdc; border: 1px solid #444; - padding: 4px 6px; border-radius: 3px; font-size: 12px; + padding: 3px 5px; border-radius: 3px; font-size: 11px; width: 100%; } -#params-form input:focus { outline: 1px solid #00c8ff; } .kv { display: flex; justify-content: space-between;