diff --git a/pm2d/web/server.py b/pm2d/web/server.py index 1532519..7cf3fcc 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -676,6 +676,102 @@ def list_recipes(): return {"files": files, "dir": str(RECIPES_DIR)} +# Cache di matcher caricati da .npz (V feature). Key: nome ricetta. +_RECIPE_MATCHERS: OrderedDict = OrderedDict() +_RECIPE_MATCHERS_SIZE = 4 + + +@app.post("/recipes/{name}/load") +def load_recipe(name: str): + """Carica ricetta .npz e popola cache matcher in memoria. + + Una volta caricata, /match_recipe la usa direttamente senza + re-train. Halcon-equivalent read_shape_model + handle. + """ + safe_name = "".join(c for c in name if c.isalnum() or c in "._-") + if not safe_name.endswith(".npz"): + safe_name += ".npz" + path = RECIPES_DIR / safe_name + if not path.is_file(): + raise HTTPException(404, f"Ricetta non trovata: {safe_name}") + m = LineShapeMatcher.load_model(str(path)) + _RECIPE_MATCHERS[safe_name] = m + _RECIPE_MATCHERS.move_to_end(safe_name) + while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE: + _RECIPE_MATCHERS.popitem(last=False) + return { + "name": safe_name, + "n_variants": len(m.variants), + "template_size": list(m.template_size), + "use_polarity": m.use_polarity, + } + + +class RecipeMatchParams(BaseModel): + recipe: str + scene_id: str + # Solo find-time params (training gia' fatto offline) + min_score: float = 0.65 + max_matches: int = 25 + min_recall: float = 0.0 + use_soft_score: bool = False + subpixel_lm: bool = False + nms_iou_threshold: float = 0.3 + coarse_stride: int = 1 + pyramid_propagate: bool = False + greediness: float = 0.0 + refine_pose_joint: bool = False + search_roi: list[int] | None = None + verify_threshold: float = 0.5 + scale_penalty: float = 0.0 + + +@app.post("/match_recipe", response_model=MatchResp) +def match_recipe(p: RecipeMatchParams): + """Match con ricetta pre-trained: zero training, solo find.""" + safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz" + m = _RECIPE_MATCHERS.get(safe_name) + if m is None: + # Auto-load on demand + path = RECIPES_DIR / safe_name + if not path.is_file(): + raise HTTPException(404, f"Ricetta non trovata: {safe_name}") + m = LineShapeMatcher.load_model(str(path)) + _RECIPE_MATCHERS[safe_name] = m + scene = _load_image(p.scene_id) + if scene is None: + raise HTTPException(404, "Scena non trovata") + search_roi_t = tuple(p.search_roi) if p.search_roi else None + t0 = time.time() + matches = m.find( + scene, + min_score=p.min_score, max_matches=p.max_matches, + verify_threshold=p.verify_threshold, + scale_penalty=p.scale_penalty, + 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 + tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) + 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=0.0, find_time=t_find, + num_variants=len(m.variants), annotated_id=ann_id, + ) + + # 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 c4b61fa..b4bd931 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -19,6 +19,7 @@ const PALETTE = [ const state = { model: null, scene: null, roi: null, drag: null, matches: [], annotatedImg: null, + active_recipe: null, // V: ricetta caricata (string nome) o null }; // ---------- Forms ---------- @@ -307,7 +308,42 @@ function setupROI() { } // ---------- Match action ---------- +async function doMatchRecipe() { + if (!state.scene) { setStatus("Carica scena"); return; } + setStatus(`Match ricetta ${state.active_recipe}...`); + const hc = readHalconFlags(); + const body = { + recipe: state.active_recipe, + scene_id: state.scene.id, + min_score: parseFloat(document.getElementById("p-min-score").value), + max_matches: parseInt(document.getElementById("p-max-matches").value, 10), + verify_threshold: 0.50, + ...hc, + }; + const r = await fetch("/match_recipe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) { 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()}`); + renderScene(); + renderLegend(); + document.getElementById("t-train").textContent = "—"; + 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 (ricetta ${state.active_recipe})`); +} + async function doMatch() { + // Path V: ricetta caricata → bypass training, solo find su scena + if (state.active_recipe) { + return doMatchRecipe(); + } if (!state.model) { setStatus("Carica modello"); return; } if (!state.scene) { setStatus("Carica scena"); return; } if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; } @@ -447,6 +483,57 @@ async function doAutoTune() { } } +// ---------- V: Recipe load/list/unload ---------- +async function refreshRecipeList() { + try { + const r = await fetch("/recipes"); + if (!r.ok) return; + const j = await r.json(); + const sel = document.getElementById("hc-recipe-list"); + const cur = sel.value; + sel.innerHTML = ''; + for (const f of j.files) { + const o = document.createElement("option"); + o.value = f.name; + o.textContent = `${f.name} (${(f.size / 1024).toFixed(1)} KB)`; + sel.appendChild(o); + } + if (cur) sel.value = cur; + } catch (e) { /* silent */ } +} + +async function loadRecipe() { + const sel = document.getElementById("hc-recipe-list"); + const name = sel.value; + if (!name) { + alert("Seleziona una ricetta dalla lista."); + return; + } + try { + const r = await fetch(`/recipes/${encodeURIComponent(name)}/load`, { + method: "POST", + }); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + state.active_recipe = j.name; + document.getElementById("recipe-status").textContent = + `Caricata: ${j.name} — ${j.n_variants} varianti, ` + + `${j.template_size[0]}x${j.template_size[1]} px` + + (j.use_polarity ? " (polarity)" : ""); + document.getElementById("recipe-status").style.color = "#0c0"; + document.getElementById("btn-unload-recipe").disabled = false; + } catch (e) { + alert(`Errore caricamento: ${e.message}`); + } +} + +function unloadRecipe() { + state.active_recipe = null; + document.getElementById("recipe-status").textContent = "Nessuna ricetta caricata"; + document.getElementById("recipe-status").style.color = "#888"; + document.getElementById("btn-unload-recipe").disabled = true; +} + // ---------- V: Save recipe ---------- async function saveRecipe() { if (!state.model || !state.roi) { @@ -480,6 +567,7 @@ async function saveRecipe() { 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`); + refreshRecipeList(); } catch (e) { alert(`Errore salvataggio: ${e.message}`); } @@ -515,6 +603,11 @@ window.addEventListener("DOMContentLoaded", async () => { document.getElementById("btn-autotune").addEventListener("click", doAutoTune); document.getElementById("btn-save-recipe").addEventListener("click", saveRecipe); + document.getElementById("btn-load-recipe").addEventListener("click", + loadRecipe); + document.getElementById("btn-unload-recipe").addEventListener("click", + unloadRecipe); + refreshRecipeList(); 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 43532dd..c73f00d 100644 --- a/pm2d/web/static/index.html +++ b/pm2d/web/static/index.html @@ -190,6 +190,16 @@ +
+ + + +
+
+ Nessuna ricetta caricata +
diff --git a/recipes/Pippo.npz b/recipes/Pippo.npz new file mode 100644 index 0000000..37d160b Binary files /dev/null and b/recipes/Pippo.npz differ