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