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,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()
|
||||
@@ -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