Compare commits

...

3 Commits

Author SHA1 Message Date
Adriano ca3882c59c feat: auto_tune self-validation (Halcon-style inspect_shape_model)
Nuovo helper _self_validate(): post-stima parametri, esegue dry-run
training+find sul template stesso e regola i parametri se subottimali.

Loop di auto-correzione (analogo a Halcon inspect_shape_model):
1. Se top-level piramide ha <8 feature → riduce pyramid_levels
2. Se train produce 0 varianti → dimezza weak/strong_grad
3. Se find sul template fallisce → riduce soglie + num_features
4. Se self-score < 0.7 → abbassa weak_grad

Costo: 1 train minimale (1 variante) + 1 find su canvas tpl + padding,
~50ms su template 100x100. Ne vale la pena per evitare match-time
errors su scene reali con parametri estimato male.

Esposto via auto_tune(self_validate=True) default; meta '_self_score'
e '_validation' nel dict risultato per logging UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:04:01 +02:00
Adriano 7cb1ae2df7 merge: UI wiring modalita Halcon 2026-05-04 22:49:17 +02:00
Adriano 6ebb08e7a2 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) <noreply@anthropic.com>
2026-05-04 22:49:11 +02:00
5 changed files with 354 additions and 0 deletions
+105
View File
@@ -152,11 +152,103 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
return h.hexdigest()
def _self_validate(template_bgr: np.ndarray, params: dict,
mask: np.ndarray | None = None) -> dict:
"""Halcon-style self-validation: train il matcher coi parametri tentativi
e verifica che il template stesso sia trovato con recall ≥ 1.0.
Se recall < target o score basso, regola i parametri:
- alza weak_grad se troppi edge spuri (recall solido ma molti picchi falsi)
- abbassa strong_grad se troppe feature scartate (low feature count)
- riduce pyramid_levels se variants[0].levels[top] ha <8 feature
Halcon usa internamente questo loop in inspect_shape_model. Costo: 1
train + 1 find sul template (~50ms su template 100x100). Ne vale la
pena se evita match-time errors su scene reali.
Mutates `params` in place e ritorna lo stesso dict per chaining.
"""
# Import lazy: evita ciclo (line_matcher importa nulla da auto_tune)
from pm2d.line_matcher import LineShapeMatcher
# Caso degenerato: troppe poche feature pre-validation → riduci soglia
if params.get("_n_strong_pixels", 0) < 30:
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.6)
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.6)
# Train minimale: 1 sola pose orientazione 0 (range degenerato che
# produce comunque 1 variante via fallback in _angle_list).
m = LineShapeMatcher(
num_features=params["num_features"],
weak_grad=params["weak_grad"],
strong_grad=params["strong_grad"],
angle_range_deg=(0.0, 0.0), # fallback _angle_list = [0.0]
angle_step_deg=10.0,
scale_range=(1.0, 1.0),
spread_radius=params["spread_radius"],
pyramid_levels=params["pyramid_levels"],
)
n_var = m.train(template_bgr, mask=mask)
if n_var == 0:
# Soglie troppo alte: nessuna variante generata → dimezza
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.5)
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.5)
params["_validation"] = "fallback: soglie dimezzate (no variants)"
return params
# Verifica densita' feature al top-level (rischio collasso)
top_lvl = m.variants[0].levels[-1]
if top_lvl.n < 8 and params["pyramid_levels"] > 1:
params["pyramid_levels"] = max(1, params["pyramid_levels"] - 1)
params["_validation"] = (
f"pyramid_levels ridotto a {params['pyramid_levels']} "
f"(top aveva {top_lvl.n} feature)"
)
return params
# Self-find: cerca il template stesso nella propria immagine
h, w = template_bgr.shape[:2]
# Embed template in scena leggermente più grande per evitare bordo
pad = 20
canvas = np.full(
(h + 2 * pad, w + 2 * pad, 3 if template_bgr.ndim == 3 else 1),
128, dtype=np.uint8,
)
canvas[pad:pad + h, pad:pad + w] = template_bgr
matches = m.find(
canvas, min_score=0.3, max_matches=5,
verify_ncc=False, # template stesso → NCC = 1 sempre, skip per velocita'
refine_angle=False, subpixel=False,
nms_iou_threshold=0.3,
)
if not matches:
# Nessun match sul proprio template: parametri troppo restrittivi
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.7)
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.7)
params["num_features"] = max(48, int(params["num_features"] * 0.8))
params["_validation"] = "soglie/feature ridotte (no self-match)"
return params
# Misura score top match
top_score = float(matches[0].score)
params["_self_score"] = round(top_score, 3)
if top_score < 0.7:
# Score basso sul template stesso = parametri davvero subottimali
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.85)
params["_validation"] = (
f"weak_grad ridotto (self-score era {top_score:.2f})"
)
else:
params["_validation"] = f"OK (self-score {top_score:.2f})"
return params
def auto_tune(
template_bgr: np.ndarray,
mask: np.ndarray | None = None,
angle_tolerance_deg: float | None = None,
angle_center_deg: float = 0.0,
self_validate: bool = True,
) -> dict:
"""Analizza template e ritorna dict parametri suggeriti.
@@ -168,6 +260,11 @@ def auto_tune(
meccanico): training molto piu rapido (24x meno varianti per
tol=15° vs 360° pieno).
self_validate: se True (default), dopo la stima dei parametri
esegue un dry-run del matching sul template stesso e regola
weak_grad/strong_grad/pyramid_levels se i parametri tentativi
non garantiscono auto-match (Halcon-style inspect_shape_model).
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
"""
ck = _cache_key(template_bgr, mask)
@@ -265,7 +362,15 @@ def auto_tune(
"_symmetry_order": sym["order"],
"_symmetry_conf": round(sym["confidence"], 2),
"_orient_entropy": round(stats["orient_entropy"], 2),
"_n_strong_pixels": stats["n_strong"],
}
# Halcon-style self-validation: dry-run training+find sul template per
# auto-correggere parametri tentativi che non garantirebbero match.
if self_validate:
result = _self_validate(template_bgr, result, mask=mask)
# Round numerici dopo eventuali aggiustamenti
result["weak_grad"] = round(result["weak_grad"], 1)
result["strong_grad"] = round(result["strong_grad"], 1)
# Store in LRU cache
_TUNE_CACHE[ck] = dict(result)
_TUNE_CACHE.move_to_end(ck)
+98
View File
@@ -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")
+73
View File
@@ -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 =
+61
View File
@@ -129,6 +129,67 @@
<input type="number" id="p-max-matches" value="25" min="1" max="200">
</div>
<details>
<summary>Modalità Halcon</summary>
<div class="halcon-grid">
<label class="hc-row" title="16-bin orientation polarity-aware (mod 2π)">
<input type="checkbox" id="hc-use-polarity">
<span>Polarity 16-bin (F)</span>
</label>
<label class="hc-row" title="Score continuo cos(θ_t-θ_s) invece di bin">
<input type="checkbox" id="hc-soft-score">
<span>Soft-margin score (Y)</span>
</label>
<label class="hc-row" title="Sub-pixel refinement gradient field LM">
<input type="checkbox" id="hc-subpixel-lm">
<span>Sub-pixel LM 0.05 px (Z)</span>
</label>
<label class="hc-row" title="Refine congiunto Nelder-Mead (cx,cy,θ)">
<input type="checkbox" id="hc-refine-joint">
<span>Refine pose joint</span>
</label>
<label class="hc-row" title="Pyramid candidates propagation">
<input type="checkbox" id="hc-pyr-propagate">
<span>Pyramid propagate</span>
</label>
<label class="hc-row" title="OpenCL GPU offload (silent fallback CPU)">
<input type="checkbox" id="hc-use-gpu">
<span>GPU OpenCL (R)</span>
</label>
<div class="hc-row hc-num">
<label>Min recall (M)</label>
<input type="number" id="hc-min-recall" value="0.0" min="0" max="1" step="0.05">
</div>
<div class="hc-row hc-num">
<label>NMS IoU thr (A)</label>
<input type="number" id="hc-nms-iou" value="0.3" min="0" max="1" step="0.05">
</div>
<div class="hc-row hc-num">
<label>Greediness</label>
<input type="number" id="hc-greediness" value="0.0" min="0" max="1" step="0.1">
</div>
<div class="hc-row hc-num">
<label>Coarse stride</label>
<input type="number" id="hc-coarse-stride" value="1" min="1" max="4" step="1">
</div>
<div class="hc-row hc-num" style="grid-column:1/-1">
<label title="Limita area di ricerca scena: x,y,w,h (vuoto = tutta scena)">
Search ROI (x,y,w,h)
</label>
<input type="text" id="hc-search-roi" placeholder="es. 100,50,800,400">
</div>
<div class="hc-row" style="grid-column:1/-1; border-top:1px solid #444; padding-top:8px">
<label>Ricetta pre-trained (V)</label>
<div style="display:flex; gap:6px; margin-top:4px">
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
</div>
</div>
</div>
</details>
<details>
<summary>Avanzate</summary>
<div id="adv-form"></div>
+17
View File
@@ -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%; }