6ebb08e7a2
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>
478 lines
16 KiB
JavaScript
478 lines
16 KiB
JavaScript
// Pattern Matching 2D - frontend (UI semplificata operator-friendly)
|
|
|
|
// 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 = [
|
|
"#00ff00", "#ffc800", "#ff6464", "#ffc800", "#c800ff",
|
|
"#64ffc8", "#ff0000", "#00ffff",
|
|
];
|
|
|
|
const state = {
|
|
model: null, scene: null, roi: null, drag: null,
|
|
matches: [], annotatedImg: null,
|
|
};
|
|
|
|
// ---------- Forms ----------
|
|
function buildAdvancedForm() {
|
|
const form = document.getElementById("adv-form");
|
|
form.innerHTML = "";
|
|
for (const [key, label, , def] of ADV_PARAMS) {
|
|
const lbl = document.createElement("label");
|
|
lbl.textContent = label;
|
|
const inp = document.createElement("input");
|
|
inp.id = `adv-${key}`;
|
|
inp.type = "text";
|
|
inp.placeholder = "auto";
|
|
inp.value = def === "" ? "" : String(def);
|
|
inp.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") doMatch();
|
|
});
|
|
form.appendChild(lbl);
|
|
form.appendChild(inp);
|
|
}
|
|
}
|
|
|
|
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,
|
|
filtro_fp: document.getElementById("p-filtro-fp").value,
|
|
penalita_scala: parseFloat(
|
|
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,
|
|
};
|
|
}
|
|
|
|
function readAdvancedOverrides() {
|
|
const out = {};
|
|
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;
|
|
}
|
|
|
|
// ---------- Image loading from folder ----------
|
|
async function loadFromFolder(filename) {
|
|
const r = await fetch("/load_from_folder", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ filename }),
|
|
});
|
|
if (!r.ok) throw new Error(await r.text());
|
|
return await r.json();
|
|
}
|
|
|
|
async function fetchImagesList() {
|
|
const r = await fetch("/images");
|
|
if (!r.ok) return { files: [], dir: "" };
|
|
return await r.json();
|
|
}
|
|
|
|
async function uploadToFolder(file) {
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
const r = await fetch("/upload_to_folder", { method: "POST", body: fd });
|
|
if (!r.ok) throw new Error(await r.text());
|
|
return await r.json();
|
|
}
|
|
|
|
async function refreshPickers() {
|
|
const {files, dir} = await fetchImagesList();
|
|
buildThumbPicker("picker-model", files, onSelectModel);
|
|
buildThumbPicker("picker-scene", files, onSelectScene);
|
|
return {files, dir};
|
|
}
|
|
|
|
function buildThumbPicker(pickerId, files, onSelect) {
|
|
const picker = document.getElementById(pickerId);
|
|
const current = picker.querySelector(".picker-current");
|
|
const list = picker.querySelector(".picker-list");
|
|
const text = current.querySelector(".picker-text");
|
|
// Rimuovi eventuale vecchia thumbnail
|
|
const oldImg = current.querySelector("img");
|
|
if (oldImg) oldImg.remove();
|
|
list.innerHTML = "";
|
|
|
|
files.forEach((f) => {
|
|
const item = document.createElement("div");
|
|
item.className = "picker-item";
|
|
const img = document.createElement("img");
|
|
img.src = `/folder_image/${encodeURIComponent(f)}?w=120`;
|
|
img.loading = "lazy";
|
|
const name = document.createElement("span");
|
|
name.className = "name"; name.textContent = f;
|
|
item.appendChild(img); item.appendChild(name);
|
|
item.addEventListener("click", () => {
|
|
// Aggiorna la visual del "current"
|
|
let thumb = current.querySelector("img");
|
|
if (!thumb) {
|
|
thumb = document.createElement("img");
|
|
current.insertBefore(thumb, text);
|
|
}
|
|
thumb.src = `/folder_image/${encodeURIComponent(f)}?w=80`;
|
|
text.textContent = f;
|
|
picker.classList.remove("open");
|
|
onSelect(f);
|
|
});
|
|
list.appendChild(item);
|
|
});
|
|
|
|
current.onclick = () => {
|
|
// Chiudi altri picker aperti
|
|
document.querySelectorAll(".thumb-picker.open")
|
|
.forEach((p) => { if (p !== picker) p.classList.remove("open"); });
|
|
picker.classList.toggle("open");
|
|
};
|
|
}
|
|
|
|
// Close picker on outside click
|
|
document.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".thumb-picker")) {
|
|
document.querySelectorAll(".thumb-picker.open")
|
|
.forEach((p) => p.classList.remove("open"));
|
|
}
|
|
});
|
|
|
|
function loadImage(src) {
|
|
return new Promise((res, rej) => {
|
|
const img = new Image();
|
|
img.onload = () => res(img);
|
|
img.onerror = rej;
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
async function onSelectModel(filename) {
|
|
if (!filename) return;
|
|
setStatus("Caricamento modello...");
|
|
try {
|
|
const meta = await loadFromFolder(filename);
|
|
const img = await loadImage(`/image/${meta.id}/raw`);
|
|
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
|
|
state.roi = null;
|
|
document.getElementById("roi-info").textContent = "ROI: (nessuna)";
|
|
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
|
|
renderModel();
|
|
} catch (e) {
|
|
setStatus(`Errore modello: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function onSelectScene(filename) {
|
|
if (!filename) return;
|
|
setStatus("Caricamento scena...");
|
|
try {
|
|
const meta = await loadFromFolder(filename);
|
|
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;
|
|
setStatus(`Scena: ${filename} ${meta.width}x${meta.height}`);
|
|
renderScene();
|
|
} catch (e) {
|
|
setStatus(`Errore scena: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// ---------- Rendering ----------
|
|
function fitToCanvas(img, cw, ch) {
|
|
const sc = Math.min(cw / img.width, ch / img.height);
|
|
const dw = img.width * sc, dh = img.height * sc;
|
|
return { sc, ox: (cw - dw) / 2, oy: (ch - dh) / 2, dw, dh };
|
|
}
|
|
|
|
function renderModel() {
|
|
const cnv = document.getElementById("c-model");
|
|
const ctx = cnv.getContext("2d");
|
|
ctx.fillStyle = "#141414";
|
|
ctx.fillRect(0, 0, cnv.width, cnv.height);
|
|
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;
|
|
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.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.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));
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
|
|
function renderScene() {
|
|
const cnv = document.getElementById("c-scene");
|
|
const ctx = cnv.getContext("2d");
|
|
ctx.fillStyle = "#141414";
|
|
ctx.fillRect(0, 0, cnv.width, cnv.height);
|
|
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 ----------
|
|
function canvasPos(cnv, ev) {
|
|
const r = cnv.getBoundingClientRect();
|
|
return { x: ev.clientX - r.left, y: ev.clientY - r.top };
|
|
}
|
|
|
|
function setupROI() {
|
|
const cnv = document.getElementById("c-model");
|
|
cnv.addEventListener("mousedown", (e) => {
|
|
if (!state.model) return;
|
|
const p = canvasPos(cnv, e);
|
|
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
|
renderModel();
|
|
});
|
|
cnv.addEventListener("mousemove", (e) => {
|
|
if (!state.drag) return;
|
|
const p = canvasPos(cnv, e);
|
|
state.drag.x1 = p.x; state.drag.y1 = p.y;
|
|
renderModel();
|
|
});
|
|
cnv.addEventListener("mouseup", () => {
|
|
if (!state.drag || !state.model) return;
|
|
const d = state.drag; state.drag = null;
|
|
if (Math.abs(d.x1 - d.x0) < 4 || Math.abs(d.y1 - d.y0) < 4) return;
|
|
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);
|
|
const iw = Math.round(Math.abs(d.x1 - d.x0) / m.scale);
|
|
const ih = Math.round(Math.abs(d.y1 - d.y0) / m.scale);
|
|
const cx0 = Math.max(0, Math.min(ix0, m.w - 1));
|
|
const cy0 = Math.max(0, Math.min(iy0, m.h - 1));
|
|
const cw = Math.max(1, Math.min(iw, m.w - cx0));
|
|
const ch = Math.max(1, Math.min(ih, m.h - cy0));
|
|
state.roi = [cx0, cy0, cw, ch];
|
|
document.getElementById("roi-info").textContent =
|
|
`ROI: ${cw}x${ch} @ (${cx0}, ${cy0})`;
|
|
renderModel();
|
|
});
|
|
}
|
|
|
|
// ---------- 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 user = readUserParams();
|
|
const adv = readAdvancedOverrides();
|
|
setStatus("Match in corso...");
|
|
|
|
// 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 = {invariante:0, 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};
|
|
// Allineato a FILTRO_FP_MAP server-side (server.py)
|
|
const FP_MAP = {off:0, leggero:0.30, medio:0.50, forte:0.70};
|
|
const [smin, smax, sstep] = SCALE_MAP[user.scala];
|
|
// NB: SYM_MAP[invariante]=0 e' valido (zero rotazioni). Uso ?? per
|
|
// distinguere "chiave mancante" da "valore zero": altrimenti 0 || 360
|
|
// collassa invariante a 360 = bug "simmetria non ha effetto".
|
|
const angMax = SYM_MAP[user.simmetria] ?? 360;
|
|
body = {
|
|
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
|
angle_min: 0, angle_max: angMax,
|
|
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 ?? (FP_MAP[user.filtro_fp] ?? 0.50),
|
|
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) {
|
|
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 = `${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${hasAdv ? " (avanzato)" : ""}`);
|
|
}
|
|
|
|
function renderLegend() {
|
|
const el = document.getElementById("legend");
|
|
el.innerHTML = "";
|
|
state.matches.forEach((m, i) => {
|
|
const div = document.createElement("div");
|
|
div.className = "legend-item";
|
|
const dot = document.createElement("span");
|
|
dot.className = "legend-dot";
|
|
dot.style.background = PALETTE[i % PALETTE.length];
|
|
div.appendChild(dot);
|
|
const txt = document.createElement("span");
|
|
txt.textContent = `#${i+1} cx=${Math.round(m.cx)} cy=${Math.round(m.cy)} `
|
|
+ `${m.angle_deg.toFixed(1)}° s=${m.scale.toFixed(2)} `
|
|
+ `score=${m.score.toFixed(3)}`;
|
|
div.appendChild(txt);
|
|
el.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function setStatus(s) {
|
|
document.getElementById("status").textContent = 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();
|
|
// Popola picker immagini da IMAGES_DIR (con thumbnail)
|
|
const {files, dir} = await refreshPickers();
|
|
if (files.length === 0) {
|
|
setStatus(`Nessuna immagine in ${dir} (carica file o configura IMAGES_DIR)`);
|
|
} else {
|
|
setStatus(`${files.length} immagini in ${dir}`);
|
|
}
|
|
|
|
// Upload file nella folder
|
|
const upEl = document.getElementById("file-upload");
|
|
upEl.addEventListener("change", async (e) => {
|
|
const f = e.target.files[0];
|
|
if (!f) return;
|
|
setStatus(`Caricamento ${f.name} nella cartella...`);
|
|
try {
|
|
const res = await uploadToFolder(f);
|
|
await refreshPickers();
|
|
setStatus(`Salvato come ${res.saved_as} (${res.files.length} file totali)`);
|
|
} catch (err) {
|
|
setStatus(`Errore upload: ${err.message}`);
|
|
}
|
|
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 =
|
|
parseFloat(e.target.value).toFixed(2);
|
|
});
|
|
renderModel();
|
|
renderScene();
|
|
});
|