feat: interfaccia web HTML (FastAPI + canvas JS)
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>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
// 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();
|
||||
});
|
||||
Reference in New Issue
Block a user