cad area update con autocontorno
This commit is contained in:
@@ -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