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
View File
+244
View File
@@ -0,0 +1,244 @@
"""FastAPI webapp standalone per PM2D.
Endpoint:
GET / → HTML UI
POST /upload → upload immagine (multipart)
POST /match → JSON params + ids → results
GET /image/{id}/raw → PNG originale
GET /image/{id}/annotated → PNG con overlay match
"""
from __future__ import annotations
import base64
import io
import time
import uuid
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pm2d.line_matcher import LineShapeMatcher, Match
from pm2d.auto_tune import auto_tune
WEB_DIR = Path(__file__).parent
STATIC_DIR = WEB_DIR / "static"
STATIC_DIR.mkdir(exist_ok=True)
# In-memory image store: id → np.ndarray BGR
_IMAGES: dict[str, np.ndarray] = {}
# Cache del last annotated: (image_id, matches_hash) → png bytes
_ANNOT_CACHE: dict[tuple[str, int], bytes] = {}
app = FastAPI(title="PM2D Webapp", version="1.0.0")
def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12]
_IMAGES[iid] = img
return iid
def _encode_png(img: np.ndarray) -> bytes:
ok, buf = cv2.imencode(".png", img)
if not ok:
raise RuntimeError("PNG encode failed")
return buf.tobytes()
def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None) -> np.ndarray:
out = scene.copy()
H, W = scene.shape[:2]
palette = [
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0),
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255),
]
for i, m in enumerate(matches):
color = palette[i % len(palette)]
if template_gray is not None:
t = template_gray
th, tw = t.shape
edge = cv2.Canny(t, 50, 150)
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t
M[1, 2] += m.cy - cy_t
warped = cv2.warpAffine(edge, M, (W, H),
flags=cv2.INTER_NEAREST, borderValue=0)
mask = warped > 0
if mask.any():
overlay = np.zeros_like(out)
overlay[mask] = color
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2)
cv2.polylines(out, [poly], True, color, 2, cv2.LINE_AA)
p0 = tuple(m.bbox_poly[0].astype(int))
p1 = tuple(m.bbox_poly[1].astype(int))
cv2.line(out, p0, p1, color, 4, cv2.LINE_AA)
cx, cy = int(round(m.cx)), int(round(m.cy))
cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA)
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
a = np.deg2rad(m.angle_deg)
cv2.arrowedLine(out, (cx, cy),
(int(cx + L * np.cos(a)), int(cy - L * np.sin(a))),
color, 2, cv2.LINE_AA, tipLength=0.2)
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
cv2.putText(out, label, (cx + 8, cy - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
return out
# ---------------- Models ----------------
class UploadResp(BaseModel):
id: str
width: int
height: int
class MatchParams(BaseModel):
model_id: str
scene_id: str
roi: list[int] # [x, y, w, h] nell'immagine modello
angle_min: float = 0.0
angle_max: float = 360.0
angle_step: float = 5.0
scale_min: float = 1.0
scale_max: float = 1.0
scale_step: float = 0.1
min_score: float = 0.55
max_matches: int = 25
nms_radius: int = 0
num_features: int = 96
weak_grad: float = 30.0
strong_grad: float = 60.0
spread_radius: int = 5
pyramid_levels: int = 3
verify_threshold: float = 0.4
class MatchResult(BaseModel):
cx: float
cy: float
angle_deg: float
scale: float
score: float
bbox_poly: list[list[float]]
class MatchResp(BaseModel):
matches: list[MatchResult]
train_time: float
find_time: float
num_variants: int
annotated_id: str
class TuneParams(BaseModel):
model_id: str
roi: list[int]
# ---------------- Endpoints ----------------
@app.get("/", response_class=HTMLResponse)
def index():
html_path = STATIC_DIR / "index.html"
return HTMLResponse(html_path.read_text(encoding="utf-8"))
@app.post("/upload", response_model=UploadResp)
async def upload(file: UploadFile = File(...)):
data = await file.read()
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(400, "Immagine non valida")
iid = _store_image(img)
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
@app.get("/image/{iid}/raw")
def image_raw(iid: str):
img = _IMAGES.get(iid)
if img is None:
raise HTTPException(404, "Image not found")
return Response(_encode_png(img), media_type="image/png")
@app.post("/match", response_model=MatchResp)
def match(p: MatchParams):
model = _IMAGES.get(p.model_id)
scene = _IMAGES.get(p.scene_id)
if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi
x = max(0, x); y = max(0, y)
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w]
m = LineShapeMatcher(
num_features=p.num_features,
weak_grad=p.weak_grad, strong_grad=p.strong_grad,
angle_range_deg=(p.angle_min, p.angle_max),
angle_step_deg=p.angle_step,
scale_range=(p.scale_min, p.scale_max),
scale_step=p.scale_step,
spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
nms = p.nms_radius if p.nms_radius > 0 else None
t0 = time.time()
matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold,
)
t_find = time.time() - t0
# Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg)
ann_id = _store_image(annotated)
return MatchResp(
matches=[MatchResult(
cx=m_.cx, cy=m_.cy, angle_deg=m_.angle_deg, scale=m_.scale,
score=m_.score,
bbox_poly=m_.bbox_poly.tolist(),
) for m_ in matches],
train_time=t_train, find_time=t_find,
num_variants=n, annotated_id=ann_id,
)
@app.post("/auto_tune")
def tune(p: TuneParams):
model = _IMAGES.get(p.model_id)
if model is None:
raise HTTPException(404, "Immagine non trovata")
x, y, w, h = p.roi
roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img)
return {k: v for k, v in t.items() if not k.startswith("_")}
# Mount static
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
def serve(host: str = "127.0.0.1", port: int = 8080):
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
serve()
+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; }