feat: input DXF + ROI poligonale + export JSON + CI Gitea
CI / test (push) Failing after 32s

- pm2d/dxf.py: rasterizzazione DXF -> template (ezdxf, flattening
  entita', scala/centratura, render edge antialiased)
- POST /upload_dxf: carica CAD come modello (size 128..2048)
- roi_poly su /match, /match_simple e POST /recipes: train con mask
  cv2.fillPoly (validazione 400 su poligoni degeneri), cache key inclusa
- UI: upload .dxf, modalita' ROI poligonale su canvas (click=vertice,
  dblclick=chiudi, reset), bottone Esporta JSON dei risultati
- .gitea/workflows/ci.yml: uv sync + ruff + pytest su push/PR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:30:46 +00:00
parent 91a6beb032
commit e3114d6255
5 changed files with 445 additions and 13 deletions
+180 -1
View File
@@ -20,6 +20,10 @@ const state = {
model: null, scene: null, roi: null, drag: null,
matches: [], annotatedImg: null,
active_recipe: null, // V: ricetta caricata (string nome) o null
// ROI poligonale: vertici [x, y] in coordinate immagine modello
polyMode: false, polyPts: [], polyClosed: false,
// Export JSON: ultimo match completo (params + risposta)
lastMatch: null,
};
// ---------- Forms ----------
@@ -148,6 +152,15 @@ async function uploadToFolder(file) {
return await r.json();
}
async function uploadDxf(file) {
// DXF: rasterizzato server-side in template grayscale (vedi pm2d/dxf.py)
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/upload_dxf", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
return await r.json();
}
async function refreshPickers() {
const {files, dir} = await fetchImagesList();
buildThumbPicker("picker-model", files, onSelectModel);
@@ -222,6 +235,7 @@ async function onSelectModel(filename) {
const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null;
state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale
document.getElementById("roi-info").textContent = "ROI: (nessuna)";
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
renderModel();
@@ -262,12 +276,36 @@ function renderModel() {
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) {
if (state.roi && !state.polyMode) {
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);
}
// ROI poligonale: path aperto giallo, chiuso verde con fill semitrasparente
if (state.polyMode && state.polyPts.length > 0) {
ctx.beginPath();
state.polyPts.forEach(([px, py], i) => {
const cx = fit.ox + px * fit.sc;
const cy = fit.oy + py * fit.sc;
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
if (state.polyClosed) {
ctx.closePath();
ctx.fillStyle = "rgba(0, 255, 128, 0.18)";
ctx.fill();
ctx.strokeStyle = "#00ff80";
} else {
ctx.strokeStyle = "#ffff00";
}
ctx.lineWidth = 2;
ctx.stroke();
// Vertici come quadratini
ctx.fillStyle = state.polyClosed ? "#00ff80" : "#ffff00";
for (const [px, py] of state.polyPts) {
ctx.fillRect(fit.ox + px * fit.sc - 2, fit.oy + py * fit.sc - 2, 4, 4);
}
}
if (state.drag) {
ctx.strokeStyle = "#ffff00";
ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
@@ -301,10 +339,35 @@ function setupROI() {
const cnv = document.getElementById("c-model");
cnv.addEventListener("mousedown", (e) => {
if (!state.model) return;
if (state.polyMode) return; // poly mode: gestito da click/dblclick
const p = canvasPos(cnv, e);
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
renderModel();
});
// ROI poligonale: click aggiunge vertice, doppio click chiude
cnv.addEventListener("click", (e) => {
if (!state.model || !state.polyMode || state.polyClosed) return;
const m = state.model;
const p = canvasPos(cnv, e);
const ix = (p.x - m.ox) / m.scale;
const iy = (p.y - m.oy) / m.scale;
if (ix < 0 || iy < 0 || ix > m.w || iy > m.h) return; // fuori immagine
const last = state.polyPts[state.polyPts.length - 1];
// Dedup: il dblclick genera anche 2 click ravvicinati
if (last && Math.hypot(ix - last[0], iy - last[1]) < 3) return;
state.polyPts.push([
Math.max(0, Math.min(Math.round(ix), m.w - 1)),
Math.max(0, Math.min(Math.round(iy), m.h - 1)),
]);
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici (doppio click o "Chiudi" per chiudere)`;
renderModel();
});
cnv.addEventListener("dblclick", (e) => {
if (!state.polyMode) return;
e.preventDefault();
closePoly();
});
cnv.addEventListener("mousemove", (e) => {
if (!state.drag) return;
const p = canvasPos(cnv, e);
@@ -331,6 +394,41 @@ function setupROI() {
});
}
// ---------- ROI poligonale ----------
function closePoly() {
if (!state.polyMode || state.polyClosed) return;
if (state.polyPts.length < 3) {
setStatus("Servono almeno 3 vertici per chiudere il poligono");
return;
}
state.polyClosed = true;
// ROI = bounding box del poligono (il server riceve anche roi_poly)
const xs = state.polyPts.map((p) => p[0]);
const ys = state.polyPts.map((p) => p[1]);
const x0 = Math.min(...xs), y0 = Math.min(...ys);
const w = Math.max(...xs) - x0, h = Math.max(...ys) - y0;
state.roi = [x0, y0, Math.max(1, w), Math.max(1, h)];
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici, bbox ${w}x${h} @ (${x0}, ${y0})`;
renderModel();
}
function resetPoly() {
state.polyPts = [];
state.polyClosed = false;
state.roi = null;
document.getElementById("roi-info").textContent = state.polyMode
? "Poligono: clicca sul modello per aggiungere vertici"
: "ROI: (nessuna)";
renderModel();
}
function getRoiPoly() {
// Poligono valido solo se in modalità poly e chiuso
return (state.polyMode && state.polyClosed && state.polyPts.length >= 3)
? state.polyPts : null;
}
// ---------- Match action ----------
async function doMatchRecipe() {
if (!state.scene) { setStatus("Carica scena"); return; }
@@ -352,6 +450,12 @@ async function doMatchRecipe() {
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
const data = await r.json();
state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: "/match_recipe", params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene();
@@ -371,7 +475,11 @@ async function doMatch() {
}
if (!state.model) { setStatus("Carica modello"); return; }
if (!state.scene) { setStatus("Carica scena"); return; }
if (state.polyMode && !state.polyClosed) {
setStatus("Chiudi il poligono (doppio click o bottone Chiudi)"); return;
}
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
const roiPoly = getRoiPoly();
const user = readUserParams();
const adv = readAdvancedOverrides();
setStatus("Match in corso...");
@@ -397,6 +505,7 @@ async function doMatch() {
const angMax = SYM_MAP[user.simmetria] ?? 360;
body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep,
@@ -412,6 +521,7 @@ async function doMatch() {
} else {
body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
...user,
};
}
@@ -426,6 +536,12 @@ async function doMatch() {
}
const data = await r.json();
state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: url, params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene();
@@ -461,6 +577,38 @@ function setStatus(s) {
document.getElementById("status").textContent = s;
}
// ---------- C: Export JSON risultati ----------
function exportMatchJSON() {
if (!state.lastMatch) {
alert("Nessun match da esportare: esegui prima un MATCH.");
return;
}
const lm = state.lastMatch;
const payload = {
timestamp: new Date().toISOString(),
image_id: lm.image_id,
endpoint: lm.endpoint,
params: lm.params,
matches: lm.response.matches.map((m) => ({
cx: m.cx, cy: m.cy, angle_deg: m.angle_deg,
scale: m.scale, score: m.score, bbox: m.bbox_poly,
})),
train_time: lm.response.train_time,
find_time: lm.response.find_time,
num_variants: lm.response.num_variants,
};
const blob = new Blob([JSON.stringify(payload, null, 2)],
{ type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
a.download = `pm2d_match_${ts}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
// ---------- Init ----------
// ---------- Edge preview (clean rumore) ----------
let _epDebounce = null;
@@ -734,6 +882,7 @@ async function saveRecipe() {
model_id: state.model.id,
scene_id: state.scene?.id || state.model.id,
roi: state.roi,
roi_poly: getRoiPoly(),
tipo: user.tipo,
simmetria: user.simmetria,
scala: user.scala,
@@ -777,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => {
upEl.addEventListener("change", async (e) => {
const f = e.target.files[0];
if (!f) return;
// A: file DXF → rasterizza server-side e usa direttamente come modello
if (f.name.toLowerCase().endsWith(".dxf")) {
setStatus(`Rasterizzazione DXF ${f.name}...`);
try {
const meta = await uploadDxf(f);
const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null;
resetPoly();
setStatus(`DXF ${f.name} rasterizzato ` +
`${meta.width}x${meta.height} — disegna ROI sul modello`);
renderModel();
} catch (err) {
setStatus(`Errore DXF: ${err.message}`);
}
e.target.value = "";
return;
}
setStatus(`Caricamento ${f.name} nella cartella...`);
try {
const res = await uploadToFolder(f);
@@ -789,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => {
});
document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
// B: ROI poligonale (toggle + chiudi + reset)
document.getElementById("roi-poly-toggle").addEventListener("change", (e) => {
state.polyMode = e.target.checked;
document.getElementById("btn-poly-close").disabled = !state.polyMode;
document.getElementById("btn-poly-reset").disabled = !state.polyMode;
resetPoly();
});
document.getElementById("btn-poly-close").addEventListener("click", closePoly);
document.getElementById("btn-poly-reset").addEventListener("click", resetPoly);
// C: export JSON ultimo match
document.getElementById("btn-export-json").addEventListener("click",
exportMatchJSON);
document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe);
document.getElementById("btn-load-recipe").addEventListener("click",
+16 -2
View File
@@ -30,9 +30,9 @@
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune
</button>
<label class="btn" title="Carica nuovo file nella cartella immagini">
<label class="btn" title="Carica nuovo file nella cartella immagini (immagine o DXF)">
⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden>
<input type="file" id="file-upload" accept="image/*,.dxf" hidden>
</label>
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
</div>
@@ -45,6 +45,15 @@
<canvas id="c-model" width="380" height="420"></canvas>
</div>
<div id="roi-info">ROI: (nessuna)</div>
<div id="roi-poly-bar" style="display:flex; gap:6px; align-items:center; margin-top:6px">
<label style="display:flex; gap:4px; align-items:center; font-size:12px; cursor:pointer">
<input type="checkbox" id="roi-poly-toggle"> ROI poligonale
</label>
<button class="btn" id="btn-poly-close" type="button" disabled
title="Chiude il poligono (equivale al doppio click)">Chiudi</button>
<button class="btn" id="btn-poly-reset" type="button" disabled
title="Cancella i vertici del poligono">Reset</button>
</div>
<details id="edge-preview-panel" style="margin-top:10px">
<summary>🔬 Anteprima edge / pulizia rumore</summary>
<div style="font-size:11px; color:#aaa; margin:4px 0">
@@ -248,6 +257,11 @@
<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>
<button class="btn" id="btn-export-json" type="button" disabled
style="margin-top:8px; width:100%"
title="Scarica i risultati dell'ultimo match in formato JSON">
⬇ Esporta JSON
</button>
<details id="diag-panel" style="margin-top:10px">
<summary>🔍 Diagnostica (CC)</summary>