fd7585acc5
Sostituisce GUI cv2/tkinter con webapp standalone:
Server (pm2d/web/server.py):
- FastAPI + uvicorn
- Endpoint: GET /, POST /upload, POST /match, POST /auto_tune,
GET /image/{id}/raw
- In-memory image store (uuid-based)
- Rendering annotated server-side via opencv (overlay bbox + edges
template warpati)
Frontend (pm2d/web/static/):
- index.html: layout 3 colonne (MODELLO | SCENA | PARAMETRI) + footer
legenda
- style.css: tema dark, CSS grid responsive
- app.js: canvas HTML5 per visualizzazione scalata fit,
ROI selection con drag mouse, form parametri live,
MATCH button, Auto-tune button
Parametri modificabili INLINE (niente dialog separata).
Enter su qualsiasi campo triggera MATCH.
Legenda match in fondo con pallino colorato + dati.
main.py ora lancia il server webapp. Deprecato ingresso GUI cv2
(pm2d/gui.py resta importable per backward compat).
Test: /match su rings_and_nuts: 3/3 ruote in 1.14s (train 0.36s + find 0.77s).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
301 lines
9.5 KiB
JavaScript
301 lines
9.5 KiB
JavaScript
// Pattern Matching 2D - frontend
|
|
|
|
const PARAMS = [
|
|
["angle_min", "Angolo min [°]", "float", 0],
|
|
["angle_max", "Angolo max [°]", "float", 360],
|
|
["angle_step", "Angolo step [°]", "float", 5],
|
|
["scale_min", "Scala min", "float", 1],
|
|
["scale_max", "Scala max", "float", 1],
|
|
["scale_step", "Scala step", "float", 0.1],
|
|
["min_score", "Score min [0..1]", "float", 0.55],
|
|
["max_matches", "Max match", "int", 25],
|
|
["nms_radius", "NMS radius (0=auto)", "int", 0],
|
|
["num_features", "Num feature", "int", 96],
|
|
["weak_grad", "Weak grad", "float", 30],
|
|
["strong_grad", "Strong grad", "float", 60],
|
|
["spread_radius", "Spread radius", "int", 5],
|
|
["pyramid_levels", "Pyramid levels", "int", 3],
|
|
["verify_threshold", "Verify NCC thr", "float", 0.4],
|
|
];
|
|
|
|
const PALETTE = [
|
|
"#00ff00", "#ffc800", "#ff6464", "#ffc800", "#c800ff",
|
|
"#64ffc8", "#ff0000", "#00ffff",
|
|
];
|
|
|
|
const state = {
|
|
model: null, // {id, w, h, img:HTMLImageElement, scale, ox, oy}
|
|
scene: null,
|
|
roi: null, // [x, y, w, h] in originale
|
|
drag: null, // {x0,y0,x1,y1} in canvas coords
|
|
matches: [],
|
|
annotatedImg: null, // HTMLImageElement annotated
|
|
};
|
|
|
|
// ---------- Params form ----------
|
|
function buildForm() {
|
|
const form = document.getElementById("params-form");
|
|
form.innerHTML = "";
|
|
for (const [key, label, type, def] of PARAMS) {
|
|
const lbl = document.createElement("label");
|
|
lbl.textContent = label;
|
|
lbl.htmlFor = `p-${key}`;
|
|
const inp = document.createElement("input");
|
|
inp.id = `p-${key}`;
|
|
inp.name = key;
|
|
inp.value = String(def);
|
|
inp.type = "text";
|
|
inp.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") doMatch();
|
|
});
|
|
form.appendChild(lbl);
|
|
form.appendChild(inp);
|
|
}
|
|
}
|
|
|
|
function readParams() {
|
|
const out = {};
|
|
for (const [key, , type] of PARAMS) {
|
|
const v = document.getElementById(`p-${key}`).value.trim();
|
|
out[key] = type === "int" ? parseInt(v, 10) : parseFloat(v);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function setParams(values) {
|
|
for (const [key] of PARAMS) {
|
|
if (values[key] !== undefined) {
|
|
document.getElementById(`p-${key}`).value = String(values[key]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Upload ----------
|
|
async function uploadFile(file) {
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
const r = await fetch("/upload", { method: "POST", body: fd });
|
|
if (!r.ok) throw new Error("upload failed");
|
|
return await r.json();
|
|
}
|
|
|
|
function loadImage(src) {
|
|
return new Promise((res, rej) => {
|
|
const img = new Image();
|
|
img.onload = () => res(img);
|
|
img.onerror = rej;
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
async function onLoadModel(file) {
|
|
setStatus("Caricamento modello...");
|
|
const meta = await uploadFile(file);
|
|
const img = await loadImage(`/image/${meta.id}/raw`);
|
|
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
|
|
state.roi = null;
|
|
setStatus(`Modello: ${file.name} ${meta.width}x${meta.height} — trascina ROI`);
|
|
renderModel();
|
|
}
|
|
|
|
async function onLoadScene(file) {
|
|
setStatus("Caricamento scena...");
|
|
const meta = await uploadFile(file);
|
|
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: ${file.name} ${meta.width}x${meta.height}`);
|
|
renderScene();
|
|
}
|
|
|
|
// ---------- Rendering ----------
|
|
function fitToCanvas(img, cw, ch) {
|
|
const sc = Math.min(cw / img.width, ch / img.height);
|
|
const dw = img.width * sc;
|
|
const dh = img.height * sc;
|
|
const ox = (cw - dw) / 2;
|
|
const oy = (ch - dh) / 2;
|
|
return { sc, ox, oy, 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);
|
|
if (state.annotatedImg) {
|
|
const fit = fitToCanvas(state.annotatedImg, cnv.width, cnv.height);
|
|
ctx.drawImage(state.annotatedImg, fit.ox, fit.oy, fit.dw, fit.dh);
|
|
} else if (state.scene) {
|
|
const fit = fitToCanvas(state.scene.img, cnv.width, cnv.height);
|
|
ctx.drawImage(state.scene.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", (e) => {
|
|
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;
|
|
// Canvas → originale
|
|
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();
|
|
});
|
|
}
|
|
|
|
// ---------- Actions ----------
|
|
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 params = readParams();
|
|
setStatus("Match in corso...");
|
|
const body = {
|
|
model_id: state.model.id,
|
|
scene_id: state.scene.id,
|
|
roi: state.roi,
|
|
...params,
|
|
};
|
|
const r = await fetch("/match", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!r.ok) {
|
|
const t = await r.text();
|
|
setStatus(`Errore: ${t}`);
|
|
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`);
|
|
}
|
|
|
|
async function doAutoTune() {
|
|
if (!state.model || !state.roi) {
|
|
setStatus("Carica modello + seleziona ROI");
|
|
return;
|
|
}
|
|
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) { setStatus("Errore auto-tune"); return; }
|
|
const tune = await r.json();
|
|
setParams(tune);
|
|
setStatus("Auto-tune applicato — premi MATCH");
|
|
}
|
|
|
|
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 ----------
|
|
window.addEventListener("DOMContentLoaded", () => {
|
|
buildForm();
|
|
setupROI();
|
|
document.getElementById("file-model").addEventListener("change", (e) => {
|
|
if (e.target.files[0]) onLoadModel(e.target.files[0]);
|
|
});
|
|
document.getElementById("file-scene").addEventListener("change", (e) => {
|
|
if (e.target.files[0]) onLoadScene(e.target.files[0]);
|
|
});
|
|
document.getElementById("btn-match").addEventListener("click", doMatch);
|
|
document.getElementById("btn-tune").addEventListener("click", doAutoTune);
|
|
renderModel();
|
|
renderScene();
|
|
});
|