Compare commits

...

9 Commits

Author SHA1 Message Date
Adriano 1cc7881a51 feat: pm2d.eval - validation harness CLI per LineShapeMatcher
Tool da CLI per misurare oggettivamente la qualita' del matcher
su dataset etichettato. Halcon ha questo solo nell'IDE (HDevelop),
qui esposto come modulo Python testabile in CI.

Format dataset JSON:
  - template + mask
  - params init matcher (override)
  - find_params (override per find())
  - scenes con ground_truth: lista pose attese (cx, cy, angle, scale,
    tolerance_px, tolerance_deg)

Metriche per scena: TP/FP/FN, precision, recall, IoU medio bbox,
tempo find. Aggregato: precision globale, recall, F1.

Match-to-GT criterio: distanza centro <= tolerance_px AND
|angle| <= tolerance_deg, oppure IoU bbox >= 0.3.

Use case:
- regressione: confronto config A vs B oggettivo
- tuning: trovare param ottimi via grid-search guidato da F1
- validazione pre-deploy: report TP/FP/FN su dataset prod

Esposto come entry-point pm2d-eval (pyproject.toml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:09:45 +02:00
Adriano 9218cb2741 chore: gitignore recipes/*.npz e rimuove Pippo.npz dal tracking
Le ricette pre-trained (binari numpy compressi) sono dati utente
specifici della macchina/ROI/template, non vanno versionati.
Rimosso Pippo.npz dal repo (mantenuto su filesystem locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:21:46 +02:00
Adriano 159f9089a5 merge: UI load ricetta 2026-05-04 23:20:52 +02:00
Adriano b718e81ccf feat(web): UI carica/stacca ricetta + match con ricetta caricata
Manca il path "load" della V feature: utente poteva salvare ricetta
ma non caricarla dalla UI. Aggiunto:

Server:
- POST /recipes/{name}/load: carica .npz in cache _RECIPE_MATCHERS
- POST /match_recipe: usa matcher caricato senza re-train (zero
  training time, solo find params propagati)

UI:
- Dropdown ricette disponibili (auto-refreshed da GET /recipes)
- Bottone "Carica" attiva ricetta + popola state.active_recipe
- Bottone "Stacca" torna al flow normale (training da ROI)
- Status indicator mostra ricetta attiva e dimensioni

doMatch dispatcha automaticamente:
- ricetta attiva → /match_recipe (no model/ROI necessari)
- altrimenti → /match o /match_simple come prima

Use case: ricetta tarata offline, deploy a runtime production senza
ricaricare modello+ROI ogni volta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:20:52 +02:00
Adriano d46197a81a merge: UI bottone auto-tune 2026-05-04 23:10:07 +02:00
Adriano 37c645984f feat(web): bottone Auto-tune nella toolbar (Halcon-style)
UI esponev gia' /auto_tune endpoint ma non c'era trigger user-facing.
Aggiunto bottone toolbar accanto a MATCH:
- Calcola tutti i parametri tecnici dalla ROI selezionata (gradient,
  feature, piramide, angle_step, simmetria)
- Esegue self-validation training+find su template
- Applica i valori derivati ai campi della sezione Avanzate
- Mostra alert con riepilogo + meta diagnostica
  (simmetria detected, self-validation result, ecc.)

Endpoint /auto_tune ora ritorna anche meta (_self_score, _validation,
_symmetry_order, _orient_entropy) per feedback UI invece di filtrarli.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:10:07 +02:00
Adriano 0e148667ec merge: auto_tune self-validation 2026-05-04 23:04:10 +02:00
Adriano b5bbca0e85 merge: hysteresis edge linking 2026-05-04 23:04:10 +02:00
Adriano 7f6571bdd1 feat: hysteresis edge linking (Halcon Contrast='auto' two-threshold)
_hysteresis_mask: edge linking via componenti connesse.
- seed = mag >= strong_grad
- weak = mag >= weak_grad
- Promuove a feature ogni componente weak che contiene almeno un
  pixel strong (connettivita' 8-vicini)

Riduce simultaneamente:
- Falsi positivi: edge debole isolato (rumore puro) escluso
- Falsi negativi: edge debole connesso a edge forte incluso
  (continuita' bordi sottili a basso contrasto)

Attivo automaticamente quando weak_grad < strong_grad. Se uguali,
fallback a sogliatura singola standard. Backward compat completo
dato che default weak=30, strong=60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:01:54 +02:00
7 changed files with 514 additions and 3 deletions
+2
View File
@@ -8,3 +8,5 @@ __pycache__/
.DS_Store .DS_Store
*.log *.log
models/ models/
# Ricette pre-trained (generate da utente, non versionare)
recipes/*.npz
+217
View File
@@ -0,0 +1,217 @@
"""CLI validation harness per LineShapeMatcher.
Usage:
python -m pm2d.eval dataset.json [opzioni]
Formato dataset (JSON):
{
"template": "path/to/template.png",
"mask": "path/to/mask.png", # opzionale
"params": { # opzionali, override su matcher init
"use_polarity": true,
"angle_step_deg": 5,
...
},
"find_params": { # opzionali, passati a find()
"min_score": 0.6,
"use_soft_score": true,
...
},
"scenes": [
{
"image": "path/to/scene1.png",
"ground_truth": [
{"cx": 320.0, "cy": 240.0, "angle_deg": 12.0,
"scale": 1.0, "tolerance_px": 5.0,
"tolerance_deg": 3.0}
]
}
]
}
Output: report precision/recall/IoU/timing per ogni scena + aggregati.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
import cv2
import numpy as np
from pm2d.line_matcher import LineShapeMatcher, _poly_iou, _oriented_bbox_polygon
def _load_image(path: str | Path) -> np.ndarray:
img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
if img is None:
raise FileNotFoundError(f"Immagine non trovata: {path}")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
return img
def _gt_to_poly(gt: dict, tw: int, th: int) -> np.ndarray:
"""Costruisce bbox poligonale per un ground truth."""
s = float(gt.get("scale", 1.0))
return _oriented_bbox_polygon(
float(gt["cx"]), float(gt["cy"]),
tw * s, th * s, float(gt["angle_deg"]),
)
def _match_to_gt(match, gt: dict, tw: int, th: int,
iou_thr: float = 0.3) -> bool:
"""True se il match corrisponde al ground truth.
Criterio: distanza centro <= tolerance_px AND |angle_deg - gt| <= tolerance_deg
OR IoU bbox >= iou_thr (fallback per pose con tolerance ampie).
"""
tol_px = float(gt.get("tolerance_px", 5.0))
tol_deg = float(gt.get("tolerance_deg", 3.0))
dx = match.cx - float(gt["cx"])
dy = match.cy - float(gt["cy"])
dist = math.hypot(dx, dy)
da = abs((match.angle_deg - float(gt["angle_deg"]) + 180) % 360 - 180)
if dist <= tol_px and da <= tol_deg:
return True
# Fallback IoU
poly_gt = _gt_to_poly(gt, tw, th)
poly_m = match.bbox_poly
if _poly_iou(poly_m, poly_gt) >= iou_thr:
return True
return False
def evaluate_scene(matcher: LineShapeMatcher, scene_bgr: np.ndarray,
gt_list: list[dict], find_params: dict,
tw: int, th: int) -> dict:
"""Esegue match e calcola TP/FP/FN per una scena."""
t0 = time.time()
matches = matcher.find(scene_bgr, **find_params)
elapsed = time.time() - t0
gt_matched = [False] * len(gt_list)
match_is_tp = [False] * len(matches)
iou_per_match = [0.0] * len(matches)
for i, m in enumerate(matches):
for j, gt in enumerate(gt_list):
if gt_matched[j]:
continue
if _match_to_gt(m, gt, tw, th):
gt_matched[j] = True
match_is_tp[i] = True
# Calcolo IoU per metrica
poly_gt = _gt_to_poly(gt, tw, th)
iou_per_match[i] = _poly_iou(m.bbox_poly, poly_gt)
break
tp = sum(match_is_tp)
fp = len(matches) - tp
fn = len(gt_list) - sum(gt_matched)
return {
"n_matches": len(matches),
"n_gt": len(gt_list),
"tp": tp, "fp": fp, "fn": fn,
"find_time_s": elapsed,
"iou_mean": float(np.mean([i for i, t in zip(iou_per_match, match_is_tp) if t])
if tp > 0 else 0.0),
"diag": (matcher.get_last_diag()
if hasattr(matcher, "get_last_diag") else None),
}
def run(dataset_path: str, scene_filter: str | None = None,
verbose: bool = False) -> dict:
"""Esegue eval su dataset, ritorna report aggregato."""
dataset_path = Path(dataset_path)
base = dataset_path.parent
with open(dataset_path) as f:
ds = json.load(f)
template = _load_image(base / ds["template"])
mask = None
if ds.get("mask"):
mask_img = cv2.imread(str(base / ds["mask"]), cv2.IMREAD_GRAYSCALE)
if mask_img is not None:
mask = (mask_img > 128).astype(np.uint8) * 255
init_params = ds.get("params", {})
find_params = ds.get("find_params", {})
matcher = LineShapeMatcher(**init_params)
n_var = matcher.train(template, mask=mask)
tw, th = matcher.template_size
print(f"Template: {ds['template']} ({tw}x{th}), {n_var} varianti")
print(f"Param matcher: {init_params}")
print(f"Param find: {find_params}")
print()
scenes = ds["scenes"]
if scene_filter:
scenes = [s for s in scenes if scene_filter in s["image"]]
rows = []
tot_tp = tot_fp = tot_fn = 0
tot_time = 0.0
for sc in scenes:
scene = _load_image(base / sc["image"])
gt = sc.get("ground_truth", [])
result = evaluate_scene(matcher, scene, gt, find_params, tw, th)
rows.append({"scene": sc["image"], **result})
tot_tp += result["tp"]; tot_fp += result["fp"]; tot_fn += result["fn"]
tot_time += result["find_time_s"]
prec = result["tp"] / max(1, result["tp"] + result["fp"])
rec = result["tp"] / max(1, result["tp"] + result["fn"])
line = (f" {sc['image']:30s} "
f"TP={result['tp']} FP={result['fp']} FN={result['fn']} "
f"P={prec:.2f} R={rec:.2f} "
f"IoU={result['iou_mean']:.2f} "
f"t={result['find_time_s']*1000:.0f}ms")
print(line)
if verbose and result["diag"] and hasattr(matcher, "_format_diag"):
print(f" diag: {matcher._format_diag(result['diag'])}")
# Aggregati
precision = tot_tp / max(1, tot_tp + tot_fp)
recall = tot_tp / max(1, tot_tp + tot_fn)
f1 = 2 * precision * recall / max(1e-9, precision + recall)
print()
print(f"AGGREGATO: precision={precision:.3f} recall={recall:.3f} "
f"F1={f1:.3f} TP={tot_tp} FP={tot_fp} FN={tot_fn}")
print(f"TIME: total={tot_time:.2f}s avg={tot_time / max(1, len(scenes)) * 1000:.0f}ms/scene")
return {
"precision": precision, "recall": recall, "f1": f1,
"tp": tot_tp, "fp": tot_fp, "fn": tot_fn,
"total_time_s": tot_time, "n_scenes": len(scenes),
"per_scene": rows,
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="pm2d-eval: validation harness per LineShapeMatcher"
)
p.add_argument("dataset", help="JSON dataset (template + scenes + GT)")
p.add_argument("--scene-filter", default=None,
help="Filtro substring sui nomi scena (debug)")
p.add_argument("--verbose", "-v", action="store_true",
help="Stampa diag dict per ogni scena")
p.add_argument("--out", default=None,
help="Salva report JSON su file")
args = p.parse_args(argv)
report = run(args.dataset, scene_filter=args.scene_filter,
verbose=args.verbose)
if args.out:
with open(args.out, "w") as f:
json.dump(report, f, indent=2)
print(f"Report salvato: {args.out}")
return 0 if report["f1"] > 0.5 else 1
if __name__ == "__main__":
sys.exit(main())
+38 -2
View File
@@ -241,13 +241,49 @@ class LineShapeMatcher:
bins = np.clip(bins, 0, N_BINS - 1) bins = np.clip(bins, 0, N_BINS - 1)
return mag, bins return mag, bins
def _hysteresis_mask(self, mag: np.ndarray) -> np.ndarray:
"""Edge mask con hysteresis (Halcon Contrast='auto' two-threshold).
Procedura:
1. seed = pixel con mag >= strong_grad (edge nitidi)
2. weak = pixel con mag >= weak_grad (edge candidati)
3. Espande seed dentro weak via componenti connesse 8-vicini
Risultato: edge debole connesso a edge forte viene PROMOSSO a
feature valida; edge debole isolato (rumore) viene SCARTATO.
Riduce sia falsi-positivi (rumore puro) sia falsi-negativi
(continuita' interrotta su edge sottili a basso contrasto).
"""
weak = (mag >= self.weak_grad).astype(np.uint8)
strong = (mag >= self.strong_grad).astype(np.uint8)
# connectedComponentsWithStats su weak: per ogni componente,
# se contiene almeno un pixel strong → tutto componente accettato
n_lab, labels = cv2.connectedComponents(weak, connectivity=8)
if n_lab <= 1:
return strong.astype(bool)
# Label dei pixel strong: marker per componenti da accettare
strong_labels = np.unique(labels[strong > 0])
strong_labels = strong_labels[strong_labels > 0] # 0 = bg
if len(strong_labels) == 0:
return strong.astype(bool)
# Mask = appartiene a label di componente "promosso"
keep = np.isin(labels, strong_labels)
return keep
def _extract_features( def _extract_features(
self, mag: np.ndarray, bins: np.ndarray, mask: np.ndarray | None, self, mag: np.ndarray, bins: np.ndarray, mask: np.ndarray | None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
if mask is not None: if mask is not None:
mag = np.where(mask > 0, mag, 0) mag = np.where(mask > 0, mag, 0)
strong = mag >= self.strong_grad # Halcon-style edge selection: hysteresis tra weak_grad e strong_grad.
ys, xs = np.where(strong) # Edge weak connessi a edge strong sono inclusi (continuita' bordi).
# Se weak_grad >= strong_grad → fallback a soglia singola strong.
if self.weak_grad < self.strong_grad:
edge = self._hysteresis_mask(mag)
else:
edge = mag >= self.strong_grad
ys, xs = np.where(edge)
if len(xs) == 0: if len(xs) == 0:
return (np.zeros(0, np.int32),) * 3 return (np.zeros(0, np.int32),) * 3
vals = mag[ys, xs] vals = mag[ys, xs]
+99 -1
View File
@@ -607,7 +607,9 @@ def tune(p: TuneParams):
x, y, w, h = p.roi x, y, w, h = p.roi
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img) t = auto_tune(roi_img)
return {k: v for k, v in t.items() if not k.startswith("_")} # Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
# _symmetry_order, _orient_entropy) per feedback UI.
return t
# --- V: Save/Load ricette pre-trained --- # --- V: Save/Load ricette pre-trained ---
@@ -674,6 +676,102 @@ def list_recipes():
return {"files": files, "dir": str(RECIPES_DIR)} 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 # Mount static
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+141
View File
@@ -19,6 +19,7 @@ const PALETTE = [
const state = { const state = {
model: null, scene: null, roi: null, drag: null, model: null, scene: null, roi: null, drag: null,
matches: [], annotatedImg: null, matches: [], annotatedImg: null,
active_recipe: null, // V: ricetta caricata (string nome) o null
}; };
// ---------- Forms ---------- // ---------- Forms ----------
@@ -307,7 +308,42 @@ function setupROI() {
} }
// ---------- Match action ---------- // ---------- 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() { 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.model) { setStatus("Carica modello"); return; }
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; } if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
@@ -400,6 +436,104 @@ function setStatus(s) {
} }
// ---------- Init ---------- // ---------- Init ----------
// ---------- Auto-tune (Halcon-style) ----------
async function doAutoTune() {
if (!state.model || !state.roi) {
alert("Seleziona modello e disegna ROI prima di Auto-tune.");
return;
}
const status = document.getElementById("status");
status.textContent = "Analisi ROI in corso...";
try {
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) throw new Error(await r.text());
const t = await r.json();
// Applica ai campi avanzati (override automatico)
for (const [key] of ADV_PARAMS) {
const el = document.getElementById(`adv-${key}`);
if (el && t[key] !== undefined) el.value = String(t[key]);
}
// Espandi la sezione Avanzate per mostrare i valori applicati
const advDetails = document.querySelector("#col-params details:last-of-type");
if (advDetails) advDetails.open = true;
// Feedback diagnostico
const lines = [
`weak/strong: ${t.weak_grad} / ${t.strong_grad}`,
`feature: ${t.num_features}, piramide: ${t.pyramid_levels}`,
`angle: [${t.angle_min}..${t.angle_max}]@${t.angle_step}°`,
];
if (t._symmetry_order > 1) {
lines.push(`simmetria rotaz. ${t._symmetry_order}x (conf ${t._symmetry_conf})`);
}
if (t._self_score !== undefined) {
lines.push(`self-validation: ${t._validation}`);
}
status.textContent = `Auto-tune OK — ${lines[0]}`;
alert("Auto-tune completato:\n\n" + lines.join("\n"));
} catch (e) {
status.textContent = `Auto-tune errore: ${e.message}`;
alert(`Errore auto-tune: ${e.message}`);
}
}
// ---------- 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 = '<option value="">— ricette disponibili —</option>';
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 ---------- // ---------- V: Save recipe ----------
async function saveRecipe() { async function saveRecipe() {
if (!state.model || !state.roi) { if (!state.model || !state.roi) {
@@ -433,6 +567,7 @@ async function saveRecipe() {
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
const j = await r.json(); const j = await r.json();
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`); alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
refreshRecipeList();
} catch (e) { } catch (e) {
alert(`Errore salvataggio: ${e.message}`); alert(`Errore salvataggio: ${e.message}`);
} }
@@ -465,8 +600,14 @@ window.addEventListener("DOMContentLoaded", async () => {
e.target.value = ""; // consente re-upload stesso file e.target.value = ""; // consente re-upload stesso file
}); });
document.getElementById("btn-match").addEventListener("click", doMatch); document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
document.getElementById("btn-save-recipe").addEventListener("click", document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe); 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"); const slider = document.getElementById("p-min-score");
slider.addEventListener("input", (e) => { slider.addEventListener("input", (e) => {
document.getElementById("v-score").textContent = document.getElementById("v-score").textContent =
+14
View File
@@ -26,6 +26,10 @@
<div class="picker-list"></div> <div class="picker-list"></div>
</div> </div>
<button class="btn btn-go" id="btn-match">▶ MATCH</button> <button class="btn btn-go" id="btn-match">▶ MATCH</button>
<button class="btn" id="btn-autotune"
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune
</button>
<label class="btn" title="Carica nuovo file nella cartella immagini"> <label class="btn" title="Carica nuovo file nella cartella immagini">
⬆ Carica file ⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden> <input type="file" id="file-upload" accept="image/*" hidden>
@@ -186,6 +190,16 @@
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1"> <input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button> <button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
</div> </div>
<div style="display:flex; gap:6px; margin-top:6px; align-items:center">
<select id="hc-recipe-list" style="flex:1">
<option value="">— ricette disponibili —</option>
</select>
<button class="btn" id="btn-load-recipe" type="button">📂 Carica</button>
<button class="btn" id="btn-unload-recipe" type="button" disabled>✖ Stacca</button>
</div>
<div id="recipe-status" style="margin-top:4px; font-size:11px; color:#888">
Nessuna ricetta caricata
</div>
</div> </div>
</div> </div>
</details> </details>
+3
View File
@@ -12,6 +12,9 @@ dependencies = [
"uvicorn[standard]>=0.34", "uvicorn[standard]>=0.34",
] ]
[project.scripts]
pm2d-eval = "pm2d.eval:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",