cad area update con autocontorno
This commit is contained in:
@@ -496,6 +496,14 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
🔍 Zoom su ROI
|
🔍 Zoom su ROI
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button type="button" id="toolAutoContourRoiBtn" class="btn btn-outline-info">
|
||||||
|
🎯 ROI autocontorno
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" id="autoContourBtn" class="btn btn-outline-primary">
|
||||||
|
🤖 Proponi contorno
|
||||||
|
</button>
|
||||||
|
|
||||||
<button type="button" id="fullPageBtn" class="btn btn-outline-dark" disabled>
|
<button type="button" id="fullPageBtn" class="btn btn-outline-dark" disabled>
|
||||||
↩️ Pagina intera
|
↩️ Pagina intera
|
||||||
</button>
|
</button>
|
||||||
@@ -571,6 +579,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
let roiStartY = 0;
|
let roiStartY = 0;
|
||||||
let roiRect = null;
|
let roiRect = null;
|
||||||
|
|
||||||
|
let isDrawingAutoContourRoi = false;
|
||||||
|
let autoContourRoiStartX = 0;
|
||||||
|
let autoContourRoiStartY = 0;
|
||||||
|
let autoContourRoiRect = null;
|
||||||
|
|
||||||
let calibrationPoints = [];
|
let calibrationPoints = [];
|
||||||
let calibrationMm = null;
|
let calibrationMm = null;
|
||||||
let calibrationPx = null;
|
let calibrationPx = null;
|
||||||
@@ -973,6 +986,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
roiStartY = 0;
|
roiStartY = 0;
|
||||||
roiRect = null;
|
roiRect = null;
|
||||||
|
|
||||||
|
isDrawingAutoContourRoi = false;
|
||||||
|
autoContourRoiStartX = 0;
|
||||||
|
autoContourRoiStartY = 0;
|
||||||
|
autoContourRoiRect = null;
|
||||||
|
|
||||||
calibrationPoints = [];
|
calibrationPoints = [];
|
||||||
calibrationMm = null;
|
calibrationMm = null;
|
||||||
calibrationPx = null;
|
calibrationPx = null;
|
||||||
@@ -1017,6 +1035,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
roiStartY = 0;
|
roiStartY = 0;
|
||||||
roiRect = null;
|
roiRect = null;
|
||||||
|
|
||||||
|
isDrawingAutoContourRoi = false;
|
||||||
|
autoContourRoiStartX = 0;
|
||||||
|
autoContourRoiStartY = 0;
|
||||||
|
autoContourRoiRect = null;
|
||||||
|
|
||||||
calibrationPoints = [];
|
calibrationPoints = [];
|
||||||
calibrationMm = null;
|
calibrationMm = null;
|
||||||
calibrationPx = null;
|
calibrationPx = null;
|
||||||
@@ -1056,6 +1079,21 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentTool === 'auto_roi') {
|
||||||
|
isDrawingAutoContourRoi = true;
|
||||||
|
autoContourRoiStartX = pos.x;
|
||||||
|
autoContourRoiStartY = pos.y;
|
||||||
|
|
||||||
|
autoContourRoiRect = {
|
||||||
|
x: autoContourRoiStartX,
|
||||||
|
y: autoContourRoiStartY,
|
||||||
|
w: 0,
|
||||||
|
h: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentTool === 'edit') {
|
if (currentTool === 'edit') {
|
||||||
selectedEditPoint = findNearestEditablePoint(pos, POINT_HIT_RADIUS);
|
selectedEditPoint = findNearestEditablePoint(pos, POINT_HIT_RADIUS);
|
||||||
|
|
||||||
@@ -1090,6 +1128,18 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentTool === 'auto_roi' && isDrawingAutoContourRoi) {
|
||||||
|
autoContourRoiRect = {
|
||||||
|
x: Math.min(autoContourRoiStartX, pos.x),
|
||||||
|
y: Math.min(autoContourRoiStartY, pos.y),
|
||||||
|
w: Math.abs(pos.x - autoContourRoiStartX),
|
||||||
|
h: Math.abs(pos.y - autoContourRoiStartY)
|
||||||
|
};
|
||||||
|
|
||||||
|
redrawManualOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentTool === 'edit' && isDraggingEditPoint && selectedEditPoint) {
|
if (currentTool === 'edit' && isDraggingEditPoint && selectedEditPoint) {
|
||||||
updateSelectedPointPosition(pos);
|
updateSelectedPointPosition(pos);
|
||||||
editDragMoved = true;
|
editDragMoved = true;
|
||||||
@@ -1118,6 +1168,23 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentTool === 'auto_roi' && isDrawingAutoContourRoi) {
|
||||||
|
isDrawingAutoContourRoi = false;
|
||||||
|
|
||||||
|
if (autoContourRoiRect && (autoContourRoiRect.w < 8 || autoContourRoiRect.h < 8)) {
|
||||||
|
autoContourRoiRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
redrawManualOverlay();
|
||||||
|
updateManualPreview();
|
||||||
|
|
||||||
|
if (autoContourRoiRect) {
|
||||||
|
setManualStatus('ROI autocontorno definita. Ora clicca “Proponi contorno”: Python analizzerà solo questa zona, senza quote/testi esterni.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentTool === 'edit' && isDraggingEditPoint) {
|
if (currentTool === 'edit' && isDraggingEditPoint) {
|
||||||
isDraggingEditPoint = false;
|
isDraggingEditPoint = false;
|
||||||
|
|
||||||
@@ -1338,6 +1405,7 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
manualOverlayCtx.clearRect(0, 0, manualOverlayCanvas.width, manualOverlayCanvas.height);
|
manualOverlayCtx.clearRect(0, 0, manualOverlayCanvas.width, manualOverlayCanvas.height);
|
||||||
|
|
||||||
drawRoi();
|
drawRoi();
|
||||||
|
drawAutoContourRoi();
|
||||||
drawCalibration();
|
drawCalibration();
|
||||||
drawPolygon();
|
drawPolygon();
|
||||||
drawHoles();
|
drawHoles();
|
||||||
@@ -1356,6 +1424,41 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
manualOverlayCtx.strokeRect(roiRect.x, roiRect.y, roiRect.w, roiRect.h);
|
manualOverlayCtx.strokeRect(roiRect.x, roiRect.y, roiRect.w, roiRect.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawAutoContourRoi() {
|
||||||
|
if (!autoContourRoiRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manualOverlayCtx.fillStyle = 'rgba(168, 85, 247, 0.12)';
|
||||||
|
manualOverlayCtx.strokeStyle = 'rgba(168, 85, 247, 0.98)';
|
||||||
|
manualOverlayCtx.lineWidth = 2;
|
||||||
|
manualOverlayCtx.setLineDash([8, 4]);
|
||||||
|
|
||||||
|
manualOverlayCtx.fillRect(
|
||||||
|
autoContourRoiRect.x,
|
||||||
|
autoContourRoiRect.y,
|
||||||
|
autoContourRoiRect.w,
|
||||||
|
autoContourRoiRect.h
|
||||||
|
);
|
||||||
|
|
||||||
|
manualOverlayCtx.strokeRect(
|
||||||
|
autoContourRoiRect.x,
|
||||||
|
autoContourRoiRect.y,
|
||||||
|
autoContourRoiRect.w,
|
||||||
|
autoContourRoiRect.h
|
||||||
|
);
|
||||||
|
|
||||||
|
manualOverlayCtx.setLineDash([]);
|
||||||
|
|
||||||
|
manualOverlayCtx.fillStyle = 'rgba(168, 85, 247, 1)';
|
||||||
|
manualOverlayCtx.font = '13px Arial';
|
||||||
|
manualOverlayCtx.fillText(
|
||||||
|
'ROI autocontorno',
|
||||||
|
autoContourRoiRect.x + 6,
|
||||||
|
Math.max(14, autoContourRoiRect.y - 6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function drawCalibration() {
|
function drawCalibration() {
|
||||||
if (calibrationPoints.length === 0) {
|
if (calibrationPoints.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1750,6 +1853,215 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function proposeAutoContour() {
|
||||||
|
if (!manualPdfCanvas || !manualOverlayCanvas) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Canvas non pronto',
|
||||||
|
text: 'Apri prima il PDF nel tracciamento manuale.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manualCurrentView !== 'roi') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'info',
|
||||||
|
title: 'Prima fai Zoom su ROI',
|
||||||
|
text: 'Per l’autocontorno devi prima disegnare la ROI principale e cliccare “Zoom su ROI”.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoContourRoiRect || autoContourRoiRect.w <= 0 || autoContourRoiRect.h <= 0) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'info',
|
||||||
|
title: 'Definisci ROI autocontorno',
|
||||||
|
text: 'Clicca “ROI autocontorno” e disegna un rettangolo stretto solo attorno al profilo, senza quote, testi o frecce.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAutoContourRoiToPython();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAutoContourRoiToPython() {
|
||||||
|
if (!mmPerPx) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'info',
|
||||||
|
title: 'Prima calibra la scala',
|
||||||
|
text: 'Consiglio: calibra prima una quota, poi proponi il contorno. Il contorno può essere generato anche senza scala, ma il calcolo area richiede calibrazione.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setManualStatus('Analisi automatica del contorno sulla ROI autocontorno...');
|
||||||
|
showOverlay();
|
||||||
|
|
||||||
|
const cropCanvas = document.createElement('canvas');
|
||||||
|
const cropCtx = cropCanvas.getContext('2d');
|
||||||
|
|
||||||
|
const sx = Math.max(0, Math.floor(autoContourRoiRect.x));
|
||||||
|
const sy = Math.max(0, Math.floor(autoContourRoiRect.y));
|
||||||
|
const sw = Math.min(
|
||||||
|
manualPdfCanvas.width - sx,
|
||||||
|
Math.max(1, Math.ceil(autoContourRoiRect.w))
|
||||||
|
);
|
||||||
|
const sh = Math.min(
|
||||||
|
manualPdfCanvas.height - sy,
|
||||||
|
Math.max(1, Math.ceil(autoContourRoiRect.h))
|
||||||
|
);
|
||||||
|
|
||||||
|
cropCanvas.width = sw;
|
||||||
|
cropCanvas.height = sh;
|
||||||
|
|
||||||
|
cropCtx.drawImage(
|
||||||
|
manualPdfCanvas,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sw,
|
||||||
|
sh
|
||||||
|
);
|
||||||
|
|
||||||
|
cropCanvas.toBlob(function(blob) {
|
||||||
|
if (!blob) {
|
||||||
|
hideOverlay();
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Errore immagine',
|
||||||
|
text: 'Impossibile generare il crop della ROI autocontorno.'
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', blob, 'auto_contour_roi.png');
|
||||||
|
formData.append('max_points', '90');
|
||||||
|
|
||||||
|
fetch('http://127.0.0.1:5055/auto-contour-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(async response => {
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Risposta non JSON da Python auto-contour:', text);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'Il servizio Python non ha restituito JSON. Prima parte risposta: ' +
|
||||||
|
text.substring(0, 180)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
hideOverlay();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Contorno non trovato',
|
||||||
|
text: data.message || 'Python non è riuscito a proporre un contorno affidabile.'
|
||||||
|
});
|
||||||
|
|
||||||
|
setManualStatus('Contorno automatico non trovato. Prova una ROI autocontorno più stretta/pulita oppure procedi manualmente.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.outer_polygon) || data.outer_polygon.length < 3) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Contorno non valido',
|
||||||
|
text: 'Il contorno proposto non contiene abbastanza punti.'
|
||||||
|
});
|
||||||
|
|
||||||
|
setManualStatus('Contorno automatico non valido. Procedi con il tracciamento manuale.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAutoContourProposal(data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
hideOverlay();
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Errore auto-contorno',
|
||||||
|
text: error.message || 'Errore durante la proposta del contorno.'
|
||||||
|
});
|
||||||
|
|
||||||
|
setManualStatus('Errore durante l’auto-contorno. Procedi manualmente.');
|
||||||
|
});
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutoContourProposal(data) {
|
||||||
|
const previousPolygon = polygonPoints.map(p => ({
|
||||||
|
x: p.x,
|
||||||
|
y: p.y
|
||||||
|
}));
|
||||||
|
const previousSelected = selectedEditPoint ? {
|
||||||
|
...selectedEditPoint
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const baseRect = autoContourRoiRect || {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: manualOverlayCanvas.width,
|
||||||
|
h: manualOverlayCanvas.height
|
||||||
|
};
|
||||||
|
|
||||||
|
const proposedPolygon = data.outer_polygon.map(point => ({
|
||||||
|
x: baseRect.x + point.x * baseRect.w,
|
||||||
|
y: baseRect.y + point.y * baseRect.h
|
||||||
|
}));
|
||||||
|
|
||||||
|
polygonPoints = proposedPolygon;
|
||||||
|
selectedEditPoint = null;
|
||||||
|
currentHolePoints = [];
|
||||||
|
lastManualResult = null;
|
||||||
|
document.getElementById('saveManualAreaBtn').disabled = true;
|
||||||
|
|
||||||
|
redrawManualOverlay();
|
||||||
|
updateManualPreview();
|
||||||
|
|
||||||
|
const diagnostics = data.diagnostics || {};
|
||||||
|
const pointsCount = polygonPoints.length;
|
||||||
|
const method = diagnostics.method || 'opencv';
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'question',
|
||||||
|
title: 'Contorno proposto',
|
||||||
|
html: `Ho trovato un possibile contorno esterno con <strong>${pointsCount}</strong> punti.<br>` +
|
||||||
|
`Metodo: <strong>${escapeHtml(String(method))}</strong><br><br>` +
|
||||||
|
`Vuoi usarlo come profilo esterno e poi modificarlo con “Modifica punti”?`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Sì, usa questo contorno',
|
||||||
|
cancelButtonText: 'No, annulla'
|
||||||
|
}).then(result => {
|
||||||
|
if (!result.isConfirmed) {
|
||||||
|
polygonPoints = previousPolygon;
|
||||||
|
selectedEditPoint = previousSelected;
|
||||||
|
redrawManualOverlay();
|
||||||
|
updateManualPreview();
|
||||||
|
setManualStatus('Proposta contorno annullata. Puoi tracciare manualmente o riprovare.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
markManualResultDirty();
|
||||||
|
setTool('edit');
|
||||||
|
setManualStatus('Contorno automatico caricato. Usa “Modifica punti” per spostare/eliminare/inserire punti, poi calcola l’area.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function calculateManualArea() {
|
function calculateManualArea() {
|
||||||
if (!mmPerPx || !calibrationMm || !calibrationPx) {
|
if (!mmPerPx || !calibrationMm || !calibrationPx) {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
@@ -1997,6 +2309,10 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
parts.push(`ROI: ${Math.round(roiRect.w)}x${Math.round(roiRect.h)} px`);
|
parts.push(`ROI: ${Math.round(roiRect.w)}x${Math.round(roiRect.h)} px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (autoContourRoiRect) {
|
||||||
|
parts.push(`ROI autocontorno: ${Math.round(autoContourRoiRect.w)}x${Math.round(autoContourRoiRect.h)} px`);
|
||||||
|
}
|
||||||
|
|
||||||
if (mmPerPx) {
|
if (mmPerPx) {
|
||||||
parts.push(`Scala: ${mmPerPx.toFixed(6)} mm/px`);
|
parts.push(`Scala: ${mmPerPx.toFixed(6)} mm/px`);
|
||||||
}
|
}
|
||||||
@@ -2028,6 +2344,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
document.getElementById('toolCalibrationBtn').classList.remove('active');
|
document.getElementById('toolCalibrationBtn').classList.remove('active');
|
||||||
document.getElementById('toolPolygonBtn').classList.remove('active');
|
document.getElementById('toolPolygonBtn').classList.remove('active');
|
||||||
|
|
||||||
|
const autoRoiBtn = document.getElementById('toolAutoContourRoiBtn');
|
||||||
|
if (autoRoiBtn) {
|
||||||
|
autoRoiBtn.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
const holeBtn = document.getElementById('toolHoleBtn');
|
const holeBtn = document.getElementById('toolHoleBtn');
|
||||||
if (holeBtn) {
|
if (holeBtn) {
|
||||||
holeBtn.classList.remove('active');
|
holeBtn.classList.remove('active');
|
||||||
@@ -2064,6 +2385,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
setManualStatus('Modalità profilo esterno: clicca i punti del contorno esterno. Il poligono viene chiuso automaticamente.');
|
setManualStatus('Modalità profilo esterno: clicca i punti del contorno esterno. Il poligono viene chiuso automaticamente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool === 'auto_roi') {
|
||||||
|
document.getElementById('toolAutoContourRoiBtn').classList.add('active');
|
||||||
|
setManualStatus('Modalità ROI autocontorno: disegna un rettangolo stretto solo attorno al profilo, escludendo quote, testi e frecce.');
|
||||||
|
}
|
||||||
|
|
||||||
if (tool === 'hole') {
|
if (tool === 'hole') {
|
||||||
document.getElementById('toolHoleBtn').classList.add('active');
|
document.getElementById('toolHoleBtn').classList.add('active');
|
||||||
setManualStatus('Modalità area da escludere: clicca i punti del foro/cavità da sottrarre, poi clicca “Chiudi esclusione”.');
|
setManualStatus('Modalità area da escludere: clicca i punti del foro/cavità da sottrarre, poi clicca “Chiudi esclusione”.');
|
||||||
@@ -2234,6 +2560,23 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
renderManualRoiViewFromCurrentRoi();
|
renderManualRoiViewFromCurrentRoi();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('toolAutoContourRoiBtn').addEventListener('click', function() {
|
||||||
|
if (manualCurrentView !== 'roi') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'info',
|
||||||
|
title: 'Prima fai Zoom su ROI',
|
||||||
|
text: 'La ROI autocontorno va disegnata dentro la vista ingrandita della sezione.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTool('auto_roi');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('autoContourBtn').addEventListener('click', function() {
|
||||||
|
proposeAutoContour();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('fullPageBtn').addEventListener('click', function() {
|
document.getElementById('fullPageBtn').addEventListener('click', function() {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
|
|||||||
Binary file not shown.
+40
-1
@@ -3,6 +3,7 @@ from flask_cors import CORS
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from cad_vector_area import calculate_pdf_vector_area
|
from cad_vector_area import calculate_pdf_vector_area
|
||||||
|
from auto_contour import propose_contour_from_image_bytes
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -110,9 +111,47 @@ def calculate():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/auto-contour-image", methods=["POST"])
|
||||||
|
def auto_contour_image():
|
||||||
|
try:
|
||||||
|
if "image" not in request.files:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"message": "No image received"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
uploaded_image = request.files["image"]
|
||||||
|
image_bytes = uploaded_image.read()
|
||||||
|
|
||||||
|
max_points_raw = request.form.get("max_points", "90").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_points = int(max_points_raw)
|
||||||
|
except ValueError:
|
||||||
|
max_points = 90
|
||||||
|
|
||||||
|
max_points = max(12, min(max_points, 250))
|
||||||
|
|
||||||
|
result = propose_contour_from_image_bytes(
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
max_points=max_points
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code = 200 if result.get("success") else 422
|
||||||
|
|
||||||
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"message": str(e),
|
||||||
|
"trace": traceback.format_exc()
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(
|
app.run(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=5055,
|
port=5055,
|
||||||
debug=True
|
debug=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contour(contour, width, height):
|
||||||
|
points = contour.reshape(-1, 2)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"x": round(float(x) / float(width), 8),
|
||||||
|
"y": round(float(y) / float(height), 8),
|
||||||
|
}
|
||||||
|
for x, y in points
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify_contour(contour, max_points=120):
|
||||||
|
if contour is None or len(contour) < 3:
|
||||||
|
return contour
|
||||||
|
|
||||||
|
perimeter = cv2.arcLength(contour, True)
|
||||||
|
|
||||||
|
if perimeter <= 0:
|
||||||
|
return contour
|
||||||
|
|
||||||
|
epsilon = max(0.8, perimeter * 0.0025)
|
||||||
|
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
||||||
|
|
||||||
|
while len(simplified) > max_points and epsilon < perimeter * 0.06:
|
||||||
|
epsilon *= 1.25
|
||||||
|
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
||||||
|
|
||||||
|
if simplified is None or len(simplified) < 3:
|
||||||
|
return contour
|
||||||
|
|
||||||
|
return simplified
|
||||||
|
|
||||||
|
|
||||||
|
def _contour_score(contour, image_area, width, height):
|
||||||
|
area = abs(cv2.contourArea(contour))
|
||||||
|
|
||||||
|
if area <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
x, y, w, h = cv2.boundingRect(contour)
|
||||||
|
|
||||||
|
if w < 8 or h < 8:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bbox_area = w * h
|
||||||
|
|
||||||
|
if bbox_area <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
area_ratio = area / image_area
|
||||||
|
bbox_ratio = bbox_area / image_area
|
||||||
|
fill_ratio = area / bbox_area
|
||||||
|
aspect = max(w, h) / max(1, min(w, h))
|
||||||
|
|
||||||
|
# Too small: usually dots/noise.
|
||||||
|
if area_ratio < 0.0004:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Too large: usually background / page.
|
||||||
|
if area_ratio > 0.96 or bbox_ratio > 0.98:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Very thin: usually dimensions/text lines.
|
||||||
|
if aspect > 40:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prefer large compact-ish shapes, but allow irregular profiles.
|
||||||
|
score = area
|
||||||
|
|
||||||
|
if 0.08 <= fill_ratio <= 0.95:
|
||||||
|
score *= 1.25
|
||||||
|
|
||||||
|
# Penalize contours glued to the border because they are often crop/background artifacts.
|
||||||
|
border_touch = (
|
||||||
|
x <= 1 or
|
||||||
|
y <= 1 or
|
||||||
|
x + w >= width - 2 or
|
||||||
|
y + h >= height - 2
|
||||||
|
)
|
||||||
|
|
||||||
|
if border_touch:
|
||||||
|
score *= 0.65
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"area": area,
|
||||||
|
"bbox": (x, y, w, h),
|
||||||
|
"area_ratio": area_ratio,
|
||||||
|
"bbox_ratio": bbox_ratio,
|
||||||
|
"fill_ratio": fill_ratio,
|
||||||
|
"aspect": aspect,
|
||||||
|
"border_touch": border_touch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _best_contour_from_mask(mask, image_area, width, height, mode_name):
|
||||||
|
contours, _hierarchy = cv2.findContours(
|
||||||
|
mask,
|
||||||
|
cv2.RETR_EXTERNAL,
|
||||||
|
cv2.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
for contour in contours:
|
||||||
|
info = _contour_score(contour, image_area, width, height)
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates.append((info["score"], contour, info))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None, {
|
||||||
|
"mode": mode_name,
|
||||||
|
"contours_total": len(contours),
|
||||||
|
"candidates": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
|
||||||
|
best_score, best_contour, best_info = candidates[0]
|
||||||
|
|
||||||
|
return best_contour, {
|
||||||
|
"mode": mode_name,
|
||||||
|
"contours_total": len(contours),
|
||||||
|
"candidates": len(candidates),
|
||||||
|
"selected": {
|
||||||
|
"score": round(float(best_score), 3),
|
||||||
|
"area": round(float(best_info["area"]), 3),
|
||||||
|
"bbox": {
|
||||||
|
"x": int(best_info["bbox"][0]),
|
||||||
|
"y": int(best_info["bbox"][1]),
|
||||||
|
"width": int(best_info["bbox"][2]),
|
||||||
|
"height": int(best_info["bbox"][3]),
|
||||||
|
},
|
||||||
|
"area_ratio": round(float(best_info["area_ratio"]), 5),
|
||||||
|
"bbox_ratio": round(float(best_info["bbox_ratio"]), 5),
|
||||||
|
"fill_ratio": round(float(best_info["fill_ratio"]), 5),
|
||||||
|
"aspect": round(float(best_info["aspect"]), 3),
|
||||||
|
"border_touch": bool(best_info["border_touch"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_small_components(mask, min_area):
|
||||||
|
num_labels, labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
||||||
|
|
||||||
|
output = np.zeros_like(mask)
|
||||||
|
|
||||||
|
for label in range(1, num_labels):
|
||||||
|
area = stats[label, cv2.CC_STAT_AREA]
|
||||||
|
|
||||||
|
if area >= min_area:
|
||||||
|
output[labels == label] = 255
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _make_masks(image):
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
image_area = width * height
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Slight blur to reduce antialias noise.
|
||||||
|
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
|
||||||
|
|
||||||
|
# Dark ink mask: lines, hatches, dots, technical strokes.
|
||||||
|
mask_dark_245 = cv2.inRange(blurred, 0, 245)
|
||||||
|
mask_dark_235 = cv2.inRange(blurred, 0, 235)
|
||||||
|
mask_dark_220 = cv2.inRange(blurred, 0, 220)
|
||||||
|
|
||||||
|
# Otsu inverse.
|
||||||
|
_t, mask_otsu = cv2.threshold(
|
||||||
|
blurred,
|
||||||
|
0,
|
||||||
|
255,
|
||||||
|
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
|
||||||
|
)
|
||||||
|
|
||||||
|
# Adaptive threshold helps on scans with grey background.
|
||||||
|
mask_adaptive = cv2.adaptiveThreshold(
|
||||||
|
blurred,
|
||||||
|
255,
|
||||||
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY_INV,
|
||||||
|
31,
|
||||||
|
7
|
||||||
|
)
|
||||||
|
|
||||||
|
base_mask = cv2.bitwise_or(mask_dark_235, mask_otsu)
|
||||||
|
base_mask = cv2.bitwise_or(base_mask, mask_adaptive)
|
||||||
|
|
||||||
|
min_component_area = max(6, int(image_area * 0.00002))
|
||||||
|
base_mask = _remove_small_components(base_mask, min_component_area)
|
||||||
|
|
||||||
|
kernel_3 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
||||||
|
kernel_5 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
kernel_9 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
||||||
|
kernel_13 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))
|
||||||
|
kernel_21 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
|
||||||
|
|
||||||
|
masks = []
|
||||||
|
|
||||||
|
# Strategy 1: normal dark mask, light close.
|
||||||
|
m1 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_5, iterations=1)
|
||||||
|
m1 = cv2.morphologyEx(m1, cv2.MORPH_OPEN, kernel_3, iterations=1)
|
||||||
|
masks.append(("dark_close_5", m1))
|
||||||
|
|
||||||
|
# Strategy 2: stronger close for broken profile lines / dotted hatches.
|
||||||
|
m2 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_9, iterations=2)
|
||||||
|
m2 = cv2.morphologyEx(m2, cv2.MORPH_OPEN, kernel_3, iterations=1)
|
||||||
|
masks.append(("dark_close_9x2", m2))
|
||||||
|
|
||||||
|
# Strategy 3: very strong close, useful when profile is made of dots/hatches.
|
||||||
|
m3 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_13, iterations=2)
|
||||||
|
m3 = cv2.morphologyEx(m3, cv2.MORPH_OPEN, kernel_5, iterations=1)
|
||||||
|
masks.append(("dark_close_13x2", m3))
|
||||||
|
|
||||||
|
# Strategy 4: Canny edges closed.
|
||||||
|
edges = cv2.Canny(blurred, 60, 180)
|
||||||
|
e1 = cv2.dilate(edges, kernel_3, iterations=1)
|
||||||
|
e1 = cv2.morphologyEx(e1, cv2.MORPH_CLOSE, kernel_9, iterations=2)
|
||||||
|
masks.append(("canny_close_9x2", e1))
|
||||||
|
|
||||||
|
# Strategy 5: flood fill from closed boundaries.
|
||||||
|
boundary = cv2.dilate(mask_dark_245, kernel_3, iterations=1)
|
||||||
|
boundary = cv2.morphologyEx(boundary, cv2.MORPH_CLOSE, kernel_9, iterations=2)
|
||||||
|
|
||||||
|
passable = cv2.bitwise_not(boundary)
|
||||||
|
flood = passable.copy()
|
||||||
|
flood_mask = np.zeros((height + 2, width + 2), dtype=np.uint8)
|
||||||
|
|
||||||
|
for x in range(width):
|
||||||
|
if flood[0, x] > 0:
|
||||||
|
cv2.floodFill(flood, flood_mask, (x, 0), 128)
|
||||||
|
if flood[height - 1, x] > 0:
|
||||||
|
cv2.floodFill(flood, flood_mask, (x, height - 1), 128)
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
if flood[y, 0] > 0:
|
||||||
|
cv2.floodFill(flood, flood_mask, (0, y), 128)
|
||||||
|
if flood[y, width - 1] > 0:
|
||||||
|
cv2.floodFill(flood, flood_mask, (width - 1, y), 128)
|
||||||
|
|
||||||
|
outside = (flood == 128).astype(np.uint8) * 255
|
||||||
|
enclosed = cv2.bitwise_not(outside)
|
||||||
|
|
||||||
|
enclosed[0, :] = 0
|
||||||
|
enclosed[-1, :] = 0
|
||||||
|
enclosed[:, 0] = 0
|
||||||
|
enclosed[:, -1] = 0
|
||||||
|
|
||||||
|
enclosed = cv2.morphologyEx(enclosed, cv2.MORPH_OPEN, kernel_5, iterations=1)
|
||||||
|
masks.append(("flood_enclosed", enclosed))
|
||||||
|
|
||||||
|
# Strategy 6: if everything is sparse, glue nearby strokes aggressively.
|
||||||
|
m6 = cv2.morphologyEx(mask_dark_220, cv2.MORPH_CLOSE, kernel_21, iterations=1)
|
||||||
|
m6 = cv2.morphologyEx(m6, cv2.MORPH_OPEN, kernel_5, iterations=1)
|
||||||
|
masks.append(("aggressive_close_21", m6))
|
||||||
|
|
||||||
|
return masks, {
|
||||||
|
"gray_mean": round(float(gray.mean()), 3),
|
||||||
|
"base_mask_pixels": int((base_mask > 0).sum()),
|
||||||
|
"image_width": width,
|
||||||
|
"image_height": height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def propose_contour_from_image_bytes(image_bytes, max_points=120):
|
||||||
|
"""
|
||||||
|
Receives a PNG/JPG image of the currently visible ROI canvas and returns
|
||||||
|
a proposed outer contour as normalized x/y points.
|
||||||
|
|
||||||
|
This is a proposal only. The frontend must allow editing.
|
||||||
|
"""
|
||||||
|
np_buffer = np.frombuffer(image_bytes, dtype=np.uint8)
|
||||||
|
image = cv2.imdecode(np_buffer, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Immagine non valida o non decodificabile."
|
||||||
|
}
|
||||||
|
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
|
||||||
|
if width < 20 or height < 20:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Immagine troppo piccola per il riconoscimento del contorno."
|
||||||
|
}
|
||||||
|
|
||||||
|
image_area = width * height
|
||||||
|
|
||||||
|
masks, base_diag = _make_masks(image)
|
||||||
|
|
||||||
|
attempts = []
|
||||||
|
best_global = None
|
||||||
|
|
||||||
|
for mode_name, mask in masks:
|
||||||
|
contour, diag = _best_contour_from_mask(
|
||||||
|
mask=mask,
|
||||||
|
image_area=image_area,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
mode_name=mode_name
|
||||||
|
)
|
||||||
|
|
||||||
|
diag["mask_pixels"] = int((mask > 0).sum())
|
||||||
|
attempts.append(diag)
|
||||||
|
|
||||||
|
if contour is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
info = _contour_score(contour, image_area, width, height)
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
score = info["score"]
|
||||||
|
|
||||||
|
if best_global is None or score > best_global["score"]:
|
||||||
|
best_global = {
|
||||||
|
"score": score,
|
||||||
|
"contour": contour,
|
||||||
|
"mode": mode_name,
|
||||||
|
"info": info,
|
||||||
|
"mask_pixels": int((mask > 0).sum()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_global is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Nessun contorno plausibile trovato. Prova una ROI più stretta o procedi manualmente.",
|
||||||
|
"diagnostics": {
|
||||||
|
**base_diag,
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simplified = _simplify_contour(best_global["contour"], max_points=max_points)
|
||||||
|
|
||||||
|
if simplified is None or len(simplified) < 3:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Il contorno trovato non ha abbastanza punti validi.",
|
||||||
|
"diagnostics": {
|
||||||
|
**base_diag,
|
||||||
|
"selected_mode": best_global["mode"],
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
area_px2 = float(abs(cv2.contourArea(simplified)))
|
||||||
|
x, y, w, h = cv2.boundingRect(simplified)
|
||||||
|
|
||||||
|
# Defensive check.
|
||||||
|
if area_px2 <= 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Il contorno trovato ha area nulla.",
|
||||||
|
"diagnostics": {
|
||||||
|
**base_diag,
|
||||||
|
"selected_mode": best_global["mode"],
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Contorno proposto correttamente.",
|
||||||
|
"outer_polygon": _normalize_contour(simplified, width, height),
|
||||||
|
"holes": [],
|
||||||
|
"diagnostics": {
|
||||||
|
**base_diag,
|
||||||
|
"selected_mode": best_global["mode"],
|
||||||
|
"points_count": int(len(simplified)),
|
||||||
|
"area_px2": round(area_px2, 3),
|
||||||
|
"bbox": {
|
||||||
|
"x": int(x),
|
||||||
|
"y": int(y),
|
||||||
|
"width": int(w),
|
||||||
|
"height": int(h)
|
||||||
|
},
|
||||||
|
"selected": {
|
||||||
|
"score": round(float(best_global["score"]), 3),
|
||||||
|
"area": round(float(best_global["info"]["area"]), 3),
|
||||||
|
"area_ratio": round(float(best_global["info"]["area_ratio"]), 5),
|
||||||
|
"bbox_ratio": round(float(best_global["info"]["bbox_ratio"]), 5),
|
||||||
|
"fill_ratio": round(float(best_global["info"]["fill_ratio"]), 5),
|
||||||
|
"aspect": round(float(best_global["info"]["aspect"]), 3),
|
||||||
|
"border_touch": bool(best_global["info"]["border_touch"]),
|
||||||
|
"mask_pixels": int(best_global["mask_pixels"]),
|
||||||
|
},
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user