From 27cbc9f4498358bb3db5cbd74bbfa13fb1116647 Mon Sep 17 00:00:00 2001 From: solocla Date: Tue, 16 Jun 2026 12:05:50 +0200 Subject: [PATCH] cad area update con autocontorno --- public/userarea/cad-area.php | 343 +++++++++++++++ .../__pycache__/auto_contour.cpython-314.pyc | Bin 0 -> 15059 bytes python-cad-area/app.py | 41 +- python-cad-area/auto_contour.py | 404 ++++++++++++++++++ 4 files changed, 787 insertions(+), 1 deletion(-) create mode 100644 python-cad-area/__pycache__/auto_contour.cpython-314.pyc create mode 100644 python-cad-area/auto_contour.py 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 0000000000000000000000000000000000000000..9ce82c6b70a2fde45fda7eac743c766d3c946bad GIT binary patch literal 15059 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 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