diff --git a/public/userarea/cad-area.php b/public/userarea/cad-area.php
index 59a36c9..9a2b2d4 100644
--- a/public/userarea/cad-area.php
+++ b/public/userarea/cad-area.php
@@ -496,6 +496,14 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
🔍 Zoom su ROI
+
+
+
+
@@ -571,6 +579,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
let roiStartY = 0;
let roiRect = null;
+ let isDrawingAutoContourRoi = false;
+ let autoContourRoiStartX = 0;
+ let autoContourRoiStartY = 0;
+ let autoContourRoiRect = null;
+
let calibrationPoints = [];
let calibrationMm = null;
let calibrationPx = null;
@@ -973,6 +986,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
roiStartY = 0;
roiRect = null;
+ isDrawingAutoContourRoi = false;
+ autoContourRoiStartX = 0;
+ autoContourRoiStartY = 0;
+ autoContourRoiRect = null;
+
calibrationPoints = [];
calibrationMm = null;
calibrationPx = null;
@@ -1017,6 +1035,11 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
roiStartY = 0;
roiRect = null;
+ isDrawingAutoContourRoi = false;
+ autoContourRoiStartX = 0;
+ autoContourRoiStartY = 0;
+ autoContourRoiRect = null;
+
calibrationPoints = [];
calibrationMm = null;
calibrationPx = null;
@@ -1056,6 +1079,21 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
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') {
selectedEditPoint = findNearestEditablePoint(pos, POINT_HIT_RADIUS);
@@ -1090,6 +1128,18 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
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) {
updateSelectedPointPosition(pos);
editDragMoved = true;
@@ -1118,6 +1168,23 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
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) {
isDraggingEditPoint = false;
@@ -1338,6 +1405,7 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
manualOverlayCtx.clearRect(0, 0, manualOverlayCanvas.width, manualOverlayCanvas.height);
drawRoi();
+ drawAutoContourRoi();
drawCalibration();
drawPolygon();
drawHoles();
@@ -1356,6 +1424,41 @@ $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
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() {
if (calibrationPoints.length === 0) {
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 ${pointsCount} punti.
` +
+ `Metodo: ${escapeHtml(String(method))}
` +
+ `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() {
if (!mmPerPx || !calibrationMm || !calibrationPx) {
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`);
}
+ if (autoContourRoiRect) {
+ parts.push(`ROI autocontorno: ${Math.round(autoContourRoiRect.w)}x${Math.round(autoContourRoiRect.h)} px`);
+ }
+
if (mmPerPx) {
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('toolPolygonBtn').classList.remove('active');
+ const autoRoiBtn = document.getElementById('toolAutoContourRoiBtn');
+ if (autoRoiBtn) {
+ autoRoiBtn.classList.remove('active');
+ }
+
const holeBtn = document.getElementById('toolHoleBtn');
if (holeBtn) {
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.');
}
+ 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') {
document.getElementById('toolHoleBtn').classList.add('active');
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();
});
+ 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() {
Swal.fire({
icon: 'warning',
diff --git a/python-cad-area/__pycache__/auto_contour.cpython-314.pyc b/python-cad-area/__pycache__/auto_contour.cpython-314.pyc
new file mode 100644
index 0000000..9ce82c6
Binary files /dev/null and b/python-cad-area/__pycache__/auto_contour.cpython-314.pyc differ
diff --git a/python-cad-area/app.py b/python-cad-area/app.py
index 3e9c323..e0b2173 100644
--- a/python-cad-area/app.py
+++ b/python-cad-area/app.py
@@ -3,6 +3,7 @@ from flask_cors import CORS
import traceback
from cad_vector_area import calculate_pdf_vector_area
+from auto_contour import propose_contour_from_image_bytes
app = Flask(__name__)
CORS(app)
@@ -110,9 +111,47 @@ def calculate():
}), 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__":
app.run(
host="127.0.0.1",
port=5055,
debug=True
- )
\ No newline at end of file
+ )
diff --git a/python-cad-area/auto_contour.py b/python-cad-area/auto_contour.py
new file mode 100644
index 0000000..6deafee
--- /dev/null
+++ b/python-cad-area/auto_contour.py
@@ -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,
+ }
+ }
\ No newline at end of file