From 6ebb08e7a2c61fbf72dc944ffe3857059c4528ca Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 4 May 2026 22:49:11 +0200 Subject: [PATCH] feat(web): wiring UI per modalita Halcon (M, Y, Z, V, X, R + altri) UI espone tutti i nuovi flag tramite sezione pieghevole "Modalita Halcon" nel pannello impostazioni. Default off = comportamento backward compat. Flag esposti (checkbox + numerici): - use_polarity (F): 16-bin orientation mod 2pi - use_gpu (R): OpenCL UMat con silent fallback CPU - use_soft_score (Y): score continuo cos(theta_t-theta_s) - subpixel_lm (Z): refinement 0.05 px gradient field - refine_pose_joint: Nelder-Mead 3D (cx,cy,theta) - pyramid_propagate: top-K propagation a full-res - min_recall (M): filtro feature-recall - nms_iou_threshold (A): IoU bbox poligonale - greediness: early-exit kernel - coarse_stride: sub-sampling top-level - search_roi: x,y,w,h area di ricerca Persistenza ricette (V): - Endpoint POST /recipes: training + save .npz in recipes/ - Endpoint GET /recipes: lista - UI: campo nome + bottone "Salva" sotto i flag Server SimpleMatchParams esteso con tutti i campi; pipeline match_simple propaga init-flags al cache key (use_polarity/use_gpu = retrain) e find-flags al m.find(). Co-Authored-By: Claude Opus 4.7 (1M context) --- pm2d/web/server.py | 98 ++++++++++++++++++++++++++++++++++++++ pm2d/web/static/app.js | 73 ++++++++++++++++++++++++++++ pm2d/web/static/index.html | 61 ++++++++++++++++++++++++ pm2d/web/static/style.css | 17 +++++++ 4 files changed, 249 insertions(+) diff --git a/pm2d/web/server.py b/pm2d/web/server.py index 3d5b092..27460ab 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -48,6 +48,10 @@ IMAGES_DIR = Path(_images_dir_raw) if not IMAGES_DIR.is_absolute(): IMAGES_DIR = PROJECT_ROOT / IMAGES_DIR +# Cartella ricette pre-trained (V feature: save/load matcher) +RECIPES_DIR = PROJECT_ROOT / "recipes" +RECIPES_DIR.mkdir(exist_ok=True) + from pm2d.line_matcher import LineShapeMatcher, Match from pm2d.auto_tune import auto_tune @@ -267,6 +271,20 @@ class SimpleMatchParams(BaseModel): penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1 min_score: float = 0.65 max_matches: int = 25 + # --- Halcon-mode flags (default off = backward compat) --- + # Init-time (richiede ri-train se cambiato) + use_polarity: bool = False # F: 16 bin orientation mod 2pi + use_gpu: bool = False # R: OpenCL UMat (silent fallback) + # Find-time (no retrain) + min_recall: float = 0.0 # M: filtra match con poche feature combaciate + use_soft_score: bool = False # Y: cosine sim continua dei gradients + subpixel_lm: bool = False # Z: precisione 0.05 px + nms_iou_threshold: float = 0.3 # A: IoU bbox poligonale + coarse_stride: int = 1 # sub-sampling top-level (>=1) + pyramid_propagate: bool = False # propagazione candidati top->full + greediness: float = 0.0 # early-exit kernel (0..1) + refine_pose_joint: bool = False # Nelder-Mead 3D (cx, cy, angle) + search_roi: list[int] | None = None # [x, y, w, h] limita area def _simple_to_technical( @@ -526,6 +544,9 @@ def match_simple(p: SimpleMatchParams): tech = _simple_to_technical(p, roi_img) key = _matcher_cache_key(roi_img, tech) + # Halcon-mode init params: incidono sul training, includere in cache key + halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}" + key = key + halcon_init_key m = _cache_get_matcher(key) if m is None: m = LineShapeMatcher( @@ -537,17 +558,30 @@ def match_simple(p: SimpleMatchParams): scale_step=tech["scale_step"], spread_radius=tech["spread_radius"], pyramid_levels=tech["pyramid_levels"], + use_polarity=p.use_polarity, + use_gpu=p.use_gpu, ) t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 _cache_put_matcher(key, m) else: n = len(m.variants); t_train = 0.0 nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None + search_roi_t = tuple(p.search_roi) if p.search_roi 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"], scale_penalty=tech.get("scale_penalty", 0.0), + # Halcon-mode flags + min_recall=p.min_recall, + use_soft_score=p.use_soft_score, + subpixel_lm=p.subpixel_lm, + nms_iou_threshold=p.nms_iou_threshold, + coarse_stride=p.coarse_stride, + pyramid_propagate=p.pyramid_propagate, + greediness=p.greediness, + refine_pose_joint=p.refine_pose_joint, + search_roi=search_roi_t, ) t_find = time.time() - t0 @@ -576,6 +610,70 @@ def tune(p: TuneParams): return {k: v for k, v in t.items() if not k.startswith("_")} +# --- V: Save/Load ricette pre-trained --- + +class SaveRecipeParams(BaseModel): + model_id: str + scene_id: str | None = None + roi: list[int] + # Riusa stessi param simple per training equivalente + tipo: str = "intero" + simmetria: str = "nessuna" + scala: str = "fissa" + precisione: str = "normale" + use_polarity: bool = False + use_gpu: bool = False + name: str # nome file ricetta (no path) + + +@app.post("/recipes") +def save_recipe(p: SaveRecipeParams): + """Allena matcher e salva su disco come ricetta riutilizzabile.""" + model = _load_image(p.model_id) + if model is None: + raise HTTPException(404, "Modello non trovato") + x, y, w, h = p.roi + 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, + tipo=p.tipo, simmetria=p.simmetria, scala=p.scala, + precisione=p.precisione, + use_polarity=p.use_polarity, use_gpu=p.use_gpu, + ) + tech = _simple_to_technical(sp, 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"], + use_polarity=p.use_polarity, + use_gpu=p.use_gpu, + ) + m.train(roi_img) + safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-") + if not safe_name: + raise HTTPException(400, "Nome ricetta non valido") + if not safe_name.endswith(".npz"): + safe_name += ".npz" + target = RECIPES_DIR / safe_name + m.save_model(str(target)) + return {"name": safe_name, "size": target.stat().st_size, + "n_variants": len(m.variants)} + + +@app.get("/recipes") +def list_recipes(): + files = [] + if RECIPES_DIR.is_dir(): + for f in sorted(RECIPES_DIR.glob("*.npz")): + files.append({"name": f.name, "size": f.stat().st_size}) + return {"files": files, "dir": str(RECIPES_DIR)} + + # Mount static app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/pm2d/web/static/app.js b/pm2d/web/static/app.js index a4f3993..03db2a0 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -52,6 +52,39 @@ function readUserParams() { document.getElementById("p-penalita-scala").value), min_score: parseFloat(document.getElementById("p-min-score").value), max_matches: parseInt(document.getElementById("p-max-matches").value, 10), + ...readHalconFlags(), + }; +} + +function readHalconFlags() { + // Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon" + const $cb = (id) => document.getElementById(id)?.checked ?? false; + const $num = (id, def) => { + const v = parseFloat(document.getElementById(id)?.value); + return Number.isFinite(v) ? v : def; + }; + const $int = (id, def) => { + const v = parseInt(document.getElementById(id)?.value, 10); + return Number.isFinite(v) ? v : def; + }; + const roiStr = document.getElementById("hc-search-roi")?.value.trim() ?? ""; + let search_roi = null; + if (roiStr) { + const p = roiStr.split(/[ ,;]+/).map((x) => parseInt(x, 10)); + if (p.length === 4 && p.every((v) => Number.isFinite(v))) search_roi = p; + } + return { + use_polarity: $cb("hc-use-polarity"), + use_gpu: $cb("hc-use-gpu"), + use_soft_score: $cb("hc-soft-score"), + subpixel_lm: $cb("hc-subpixel-lm"), + refine_pose_joint: $cb("hc-refine-joint"), + pyramid_propagate: $cb("hc-pyr-propagate"), + min_recall: $num("hc-min-recall", 0), + nms_iou_threshold: $num("hc-nms-iou", 0.3), + greediness: $num("hc-greediness", 0), + coarse_stride: $int("hc-coarse-stride", 1), + search_roi: search_roi, }; } @@ -367,6 +400,44 @@ function setStatus(s) { } // ---------- Init ---------- +// ---------- V: Save recipe ---------- +async function saveRecipe() { + if (!state.model || !state.roi) { + alert("Seleziona modello e disegna ROI prima di salvare la ricetta."); + return; + } + const name = document.getElementById("hc-recipe-name").value.trim(); + if (!name) { + alert("Inserisci un nome per la ricetta."); + return; + } + const user = readUserParams(); + const body = { + model_id: state.model.id, + scene_id: state.scene?.id || state.model.id, + roi: state.roi, + tipo: user.tipo, + simmetria: user.simmetria, + scala: user.scala, + precisione: user.precisione, + use_polarity: user.use_polarity, + use_gpu: user.use_gpu, + name: name, + }; + try { + const r = await fetch("/recipes", { + 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(); + alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`); + } catch (e) { + alert(`Errore salvataggio: ${e.message}`); + } +} + window.addEventListener("DOMContentLoaded", async () => { buildAdvancedForm(); setupROI(); @@ -394,6 +465,8 @@ window.addEventListener("DOMContentLoaded", async () => { e.target.value = ""; // consente re-upload stesso file }); document.getElementById("btn-match").addEventListener("click", doMatch); + document.getElementById("btn-save-recipe").addEventListener("click", + saveRecipe); 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 aadd0e0..55679bf 100644 --- a/pm2d/web/static/index.html +++ b/pm2d/web/static/index.html @@ -129,6 +129,67 @@ +
+ Modalità Halcon +
+ + + + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+
+
Avanzate
diff --git a/pm2d/web/static/style.css b/pm2d/web/static/style.css index 6c02fd7..e13d55b 100644 --- a/pm2d/web/static/style.css +++ b/pm2d/web/static/style.css @@ -156,3 +156,20 @@ footer h2 { } #col-model, #col-scene { min-width: 0; } + +/* Halcon-mode panel */ +.halcon-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; + margin-top: 6px; + font-size: 12px; +} +.hc-row { + display: flex; align-items: center; gap: 6px; +} +.hc-row.hc-num { + flex-direction: column; align-items: flex-start; +} +.hc-row.hc-num label { font-size: 11px; color: #aaa; } +.hc-row.hc-num input { width: 100%; }