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, } }