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:
2026-04-24 09:06:25 +02:00
parent 4ddda1ec62
commit fd7585acc5
8 changed files with 1234 additions and 20 deletions
+300
View File
@@ -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();
});
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Pattern Matching 2D</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>Pattern Matching 2D</h1>
<div class="toolbar">
<label class="btn">📂 Modello
<input type="file" id="file-model" accept="image/*" hidden>
</label>
<label class="btn">📂 Scena
<input type="file" id="file-scene" accept="image/*" hidden>
</label>
<button class="btn" id="btn-tune">Auto-tune</button>
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
<span id="status">Carica modello + disegna ROI + carica scena</span>
</div>
</header>
<main>
<section class="col" id="col-model">
<h2>MODELLO</h2>
<div class="canvas-wrap">
<canvas id="c-model" width="380" height="420"></canvas>
</div>
<div id="roi-info">ROI: (nessuna)</div>
</section>
<section class="col" id="col-scene">
<h2>SCENA</h2>
<div class="canvas-wrap">
<canvas id="c-scene" width="820" height="620"></canvas>
</div>
</section>
<section class="col" id="col-params">
<h2>PARAMETRI</h2>
<div id="params-form"></div>
<h2 style="margin-top:16px">TEMPI</h2>
<div class="kv"><span>train:</span><span id="t-train">-</span></div>
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
</section>
</main>
<footer>
<h2>LEGENDA</h2>
<div id="legend"></div>
</footer>
<script src="/static/app.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; background: #1a1a1a; color: #dcdcdc;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
min-height: 100vh;
}
header {
background: #111; padding: 10px 16px; border-bottom: 1px solid #333;
position: sticky; top: 0; z-index: 10;
}
header h1 {
margin: 0 0 8px; font-size: 18px; color: #00c8ff;
}
.toolbar {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
}
.btn {
display: inline-block; padding: 6px 14px; background: #2c2c2c;
border: 1px solid #444; color: #dcdcdc; cursor: pointer;
border-radius: 4px; font-size: 13px; line-height: 1.4;
transition: background 0.15s;
}
.btn:hover { background: #3a3a3a; }
.btn-go {
background: #0a7d3a; border-color: #0d9c48; color: #fff; font-weight: bold;
}
.btn-go:hover { background: #0d9c48; }
#status {
color: #00c8ff; margin-left: 12px; font-weight: 500;
}
main {
display: grid;
grid-template-columns: 420px 1fr 360px;
gap: 12px;
padding: 12px 16px;
}
.col {
background: #232323; padding: 10px;
border: 1px solid #333; border-radius: 4px;
}
.col h2 {
margin: 0 0 8px; font-size: 13px; color: #00c8ff;
letter-spacing: 1px; text-transform: uppercase;
}
.canvas-wrap {
background: #141414; border: 1px solid #444;
display: inline-block; position: relative;
}
canvas {
display: block; cursor: crosshair;
image-rendering: pixelated;
}
#roi-info {
margin-top: 6px; font-size: 12px; color: #aaa;
}
#params-form {
display: grid; grid-template-columns: 1fr 100px; gap: 4px 8px;
}
#params-form label {
font-size: 12px; display: flex; align-items: center;
}
#params-form input {
background: #2a2a2a; color: #dcdcdc; border: 1px solid #444;
padding: 4px 6px; border-radius: 3px; font-size: 12px;
width: 100%;
}
#params-form input:focus { outline: 1px solid #00c8ff; }
.kv {
display: flex; justify-content: space-between;
padding: 3px 0; font-size: 12px; border-bottom: 1px dotted #333;
}
.kv span:last-child { color: #80ff80; font-weight: bold; }
footer {
padding: 10px 16px; border-top: 1px solid #333;
min-height: 120px;
}
footer h2 {
margin: 0 0 8px; font-size: 13px; color: #00c8ff;
letter-spacing: 1px; text-transform: uppercase;
}
#legend {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 6px;
}
.legend-item {
display: flex; align-items: center; gap: 8px;
padding: 4px 6px; background: #232323;
border-radius: 3px; font-size: 12px; font-family: monospace;
}
.legend-dot {
width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0;
}
#col-model, #col-scene { min-width: 0; }