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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user