776 lines
20 KiB
Python
776 lines
20 KiB
Python
import math
|
|
import re
|
|
from collections import Counter, deque
|
|
|
|
import fitz # PyMuPDF
|
|
import numpy as np
|
|
from shapely.geometry import Polygon
|
|
from shapely.ops import unary_union
|
|
from shapely.validation import make_valid
|
|
|
|
|
|
POINT_TO_MM = 25.4 / 72.0
|
|
DEFAULT_RENDER_ZOOM = 8.0
|
|
MAX_RENDER_SIDE_PX = 3200
|
|
STITCH_TOLERANCE = 1.2
|
|
MAX_ASPECT_RATIO = 80
|
|
|
|
|
|
_SCALE_PATTERN = re.compile(
|
|
r'(?:scale|echelle|échelle|scala|masstab|escala)?\s*'
|
|
r'(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)',
|
|
re.IGNORECASE
|
|
)
|
|
|
|
|
|
def _pt(point):
|
|
return float(point.x), float(point.y)
|
|
|
|
|
|
def _dist(a, b):
|
|
return math.hypot(a[0] - b[0], a[1] - b[1])
|
|
|
|
|
|
def _color_close(c1, c2, tol=0.05):
|
|
if c1 is None or c2 is None:
|
|
return False
|
|
|
|
if len(c1) == 4:
|
|
c1 = c1[:3]
|
|
|
|
if len(c2) == 4:
|
|
c2 = c2[:3]
|
|
|
|
if len(c1) != len(c2):
|
|
return False
|
|
|
|
return all(abs(float(a) - float(b)) <= tol for a, b in zip(c1, c2))
|
|
|
|
|
|
def _cubic_bezier(p0, p1, p2, p3, steps=24):
|
|
pts = []
|
|
|
|
for i in range(1, steps + 1):
|
|
t = i / steps
|
|
|
|
x = (
|
|
(1 - t) ** 3 * p0[0]
|
|
+ 3 * (1 - t) ** 2 * t * p1[0]
|
|
+ 3 * (1 - t) * t ** 2 * p2[0]
|
|
+ t ** 3 * p3[0]
|
|
)
|
|
|
|
y = (
|
|
(1 - t) ** 3 * p0[1]
|
|
+ 3 * (1 - t) ** 2 * t * p1[1]
|
|
+ 3 * (1 - t) * t ** 2 * p2[1]
|
|
+ t ** 3 * p3[1]
|
|
)
|
|
|
|
pts.append((x, y))
|
|
|
|
return pts
|
|
|
|
|
|
def _safe_polygon(points):
|
|
if len(points) < 3:
|
|
return None
|
|
|
|
try:
|
|
polygon = Polygon(points)
|
|
|
|
if not polygon.is_valid:
|
|
polygon = make_valid(polygon)
|
|
|
|
if polygon.is_empty or polygon.area <= 0:
|
|
return None
|
|
|
|
if polygon.geom_type == "MultiPolygon":
|
|
polygon = max(list(polygon.geoms), key=lambda g: g.area)
|
|
|
|
if polygon.geom_type != "Polygon":
|
|
return None
|
|
|
|
return polygon
|
|
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _area_mm2_from_polygon(polygon, scale_ratio):
|
|
return abs(float(polygon.area)) * (POINT_TO_MM ** 2) / (scale_ratio ** 2)
|
|
|
|
|
|
def _bounds_mm_from_polygon(polygon, scale_ratio):
|
|
minx, miny, maxx, maxy = polygon.bounds
|
|
|
|
width_mm = (maxx - minx) * POINT_TO_MM / scale_ratio
|
|
height_mm = (maxy - miny) * POINT_TO_MM / scale_ratio
|
|
|
|
return round(width_mm, 3), round(height_mm, 3)
|
|
|
|
|
|
def _detect_scale_from_text(page):
|
|
raw = page.get_text("text")
|
|
|
|
if not raw:
|
|
return None
|
|
|
|
candidates = []
|
|
|
|
for match in _SCALE_PATTERN.finditer(raw):
|
|
try:
|
|
a = float(match.group(1))
|
|
b = float(match.group(2))
|
|
|
|
if b <= 0:
|
|
continue
|
|
|
|
ratio = a / b
|
|
|
|
if 0.01 <= ratio <= 100:
|
|
candidates.append(round(ratio, 4))
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
if not candidates:
|
|
return None
|
|
|
|
return Counter(candidates).most_common(1)[0][0]
|
|
|
|
|
|
def _normalized_roi_to_page_rect(page_rect, roi):
|
|
if not roi:
|
|
return None
|
|
|
|
x = roi.get("x")
|
|
y = roi.get("y")
|
|
width = roi.get("width")
|
|
height = roi.get("height")
|
|
|
|
if x is None or y is None or width is None or height is None:
|
|
return None
|
|
|
|
try:
|
|
x = float(x)
|
|
y = float(y)
|
|
width = float(width)
|
|
height = float(height)
|
|
except Exception:
|
|
return None
|
|
|
|
if width <= 0 or height <= 0:
|
|
return None
|
|
|
|
x = max(0.0, min(1.0, x))
|
|
y = max(0.0, min(1.0, y))
|
|
width = max(0.0, min(1.0 - x, width))
|
|
height = max(0.0, min(1.0 - y, height))
|
|
|
|
x0 = page_rect.x0 + x * page_rect.width
|
|
y0 = page_rect.y0 + y * page_rect.height
|
|
x1 = x0 + width * page_rect.width
|
|
y1 = y0 + height * page_rect.height
|
|
|
|
return fitz.Rect(x0, y0, x1, y1)
|
|
|
|
|
|
def _drawing_intersects_rect(drawing, roi_rect):
|
|
if roi_rect is None:
|
|
return True
|
|
|
|
rect = drawing.get("rect")
|
|
|
|
if rect is None:
|
|
return False
|
|
|
|
try:
|
|
return fitz.Rect(rect).intersects(roi_rect)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _filter_drawings_by_roi(drawings, roi_rect):
|
|
if roi_rect is None:
|
|
return drawings
|
|
|
|
return [d for d in drawings if _drawing_intersects_rect(d, roi_rect)]
|
|
|
|
|
|
def _choose_render_zoom(rect):
|
|
zoom = DEFAULT_RENDER_ZOOM
|
|
|
|
max_side = max(rect.width, rect.height)
|
|
|
|
if max_side * zoom > MAX_RENDER_SIDE_PX:
|
|
zoom = MAX_RENDER_SIDE_PX / max_side
|
|
|
|
return max(3.0, min(DEFAULT_RENDER_ZOOM, zoom))
|
|
|
|
|
|
def _dilate(mask, iterations=1):
|
|
result = mask.astype(bool)
|
|
|
|
for _ in range(iterations):
|
|
padded = np.pad(result, 1, mode="constant", constant_values=False)
|
|
|
|
result = (
|
|
padded[1:-1, 1:-1]
|
|
| padded[:-2, 1:-1]
|
|
| padded[2:, 1:-1]
|
|
| padded[1:-1, :-2]
|
|
| padded[1:-1, 2:]
|
|
| padded[:-2, :-2]
|
|
| padded[:-2, 2:]
|
|
| padded[2:, :-2]
|
|
| padded[2:, 2:]
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _erode(mask, iterations=1):
|
|
result = mask.astype(bool)
|
|
|
|
for _ in range(iterations):
|
|
padded = np.pad(result, 1, mode="constant", constant_values=False)
|
|
|
|
result = (
|
|
padded[1:-1, 1:-1]
|
|
& padded[:-2, 1:-1]
|
|
& padded[2:, 1:-1]
|
|
& padded[1:-1, :-2]
|
|
& padded[1:-1, 2:]
|
|
& padded[:-2, :-2]
|
|
& padded[:-2, 2:]
|
|
& padded[2:, :-2]
|
|
& padded[2:, 2:]
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _close_mask(mask, iterations=2):
|
|
return _erode(_dilate(mask, iterations=iterations), iterations=iterations)
|
|
|
|
|
|
def _largest_component(mask):
|
|
h, w = mask.shape
|
|
visited = np.zeros_like(mask, dtype=bool)
|
|
|
|
best_pixels = []
|
|
best_count = 0
|
|
|
|
ys, xs = np.where(mask)
|
|
|
|
for start_y, start_x in zip(ys, xs):
|
|
if visited[start_y, start_x]:
|
|
continue
|
|
|
|
q = deque()
|
|
q.append((start_y, start_x))
|
|
visited[start_y, start_x] = True
|
|
|
|
pixels = []
|
|
|
|
while q:
|
|
y, x = q.popleft()
|
|
pixels.append((y, x))
|
|
|
|
for ny in (y - 1, y, y + 1):
|
|
for nx in (x - 1, x, x + 1):
|
|
if ny == y and nx == x:
|
|
continue
|
|
|
|
if ny < 0 or nx < 0 or ny >= h or nx >= w:
|
|
continue
|
|
|
|
if visited[ny, nx]:
|
|
continue
|
|
|
|
if not mask[ny, nx]:
|
|
continue
|
|
|
|
visited[ny, nx] = True
|
|
q.append((ny, nx))
|
|
|
|
if len(pixels) > best_count:
|
|
best_count = len(pixels)
|
|
best_pixels = pixels
|
|
|
|
output = np.zeros_like(mask, dtype=bool)
|
|
|
|
for y, x in best_pixels:
|
|
output[y, x] = True
|
|
|
|
return output, best_count
|
|
|
|
|
|
def _flood_fill_outside(boundary_mask):
|
|
h, w = boundary_mask.shape
|
|
|
|
outside = np.zeros_like(boundary_mask, dtype=bool)
|
|
passable = ~boundary_mask
|
|
|
|
q = deque()
|
|
|
|
for x in range(w):
|
|
if passable[0, x]:
|
|
outside[0, x] = True
|
|
q.append((0, x))
|
|
|
|
if passable[h - 1, x]:
|
|
outside[h - 1, x] = True
|
|
q.append((h - 1, x))
|
|
|
|
for y in range(h):
|
|
if passable[y, 0]:
|
|
outside[y, 0] = True
|
|
q.append((y, 0))
|
|
|
|
if passable[y, w - 1]:
|
|
outside[y, w - 1] = True
|
|
q.append((y, w - 1))
|
|
|
|
while q:
|
|
y, x = q.popleft()
|
|
|
|
for ny, nx in (
|
|
(y - 1, x),
|
|
(y + 1, x),
|
|
(y, x - 1),
|
|
(y, x + 1),
|
|
):
|
|
if ny < 0 or nx < 0 or ny >= h or nx >= w:
|
|
continue
|
|
|
|
if outside[ny, nx]:
|
|
continue
|
|
|
|
if not passable[ny, nx]:
|
|
continue
|
|
|
|
outside[ny, nx] = True
|
|
q.append((ny, nx))
|
|
|
|
return outside
|
|
|
|
|
|
def _bbox_from_mask(mask, padding=8):
|
|
ys, xs = np.where(mask)
|
|
|
|
if len(xs) == 0 or len(ys) == 0:
|
|
return None
|
|
|
|
h, w = mask.shape
|
|
|
|
x0 = max(0, int(xs.min()) - padding)
|
|
x1 = min(w - 1, int(xs.max()) + padding)
|
|
y0 = max(0, int(ys.min()) - padding)
|
|
y1 = min(h - 1, int(ys.max()) + padding)
|
|
|
|
return x0, y0, x1, y1
|
|
|
|
|
|
def _crop_mask(mask, bbox):
|
|
x0, y0, x1, y1 = bbox
|
|
return mask[y0:y1 + 1, x0:x1 + 1]
|
|
|
|
|
|
def _raster_roi_area(page, roi_rect, scale_ratio, mode="auto_roi"):
|
|
"""
|
|
Raster ROI method:
|
|
- render only the selected ROI
|
|
- detect technical ink / filled geometry
|
|
- try flood-fill to recover the enclosed section area
|
|
- fallback to filled dark pixels for filled profiles
|
|
"""
|
|
if roi_rect is None:
|
|
return None, "ROI mancante: definisci prima la sezione da misurare."
|
|
|
|
zoom = _choose_render_zoom(roi_rect)
|
|
|
|
pix = page.get_pixmap(
|
|
matrix=fitz.Matrix(zoom, zoom),
|
|
clip=roi_rect,
|
|
alpha=False
|
|
)
|
|
|
|
width = pix.width
|
|
height = pix.height
|
|
channels = pix.n
|
|
|
|
arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape((height, width, channels))
|
|
|
|
if channels >= 3:
|
|
rgb = arr[:, :, :3].astype(np.int16)
|
|
else:
|
|
rgb = np.repeat(arr[:, :, :1], 3, axis=2).astype(np.int16)
|
|
|
|
brightness = rgb.mean(axis=2)
|
|
max_channel = rgb.max(axis=2)
|
|
min_channel = rgb.min(axis=2)
|
|
saturation = max_channel - min_channel
|
|
|
|
# Technical ink / profile geometry.
|
|
# Keep black, grey, colored CAD strokes, anti-aliased edges.
|
|
ink = (brightness < 245) | ((saturation > 25) & (brightness < 252))
|
|
|
|
# Remove very light background noise.
|
|
ink = _close_mask(ink, iterations=1)
|
|
|
|
ink_bbox = _bbox_from_mask(ink, padding=12)
|
|
|
|
if ink_bbox is None:
|
|
return None, "Nessuna geometria visibile trovata dentro la ROI."
|
|
|
|
cropped_ink = _crop_mask(ink, ink_bbox)
|
|
|
|
# Strengthen thin CAD lines to close small gaps.
|
|
boundary = _dilate(cropped_ink, iterations=2)
|
|
boundary = _close_mask(boundary, iterations=2)
|
|
|
|
outside = _flood_fill_outside(boundary)
|
|
filled_inside = ~outside
|
|
|
|
# Keep only the largest enclosed/filled component to avoid text or small debris.
|
|
largest_inside, largest_inside_pixels = _largest_component(filled_inside)
|
|
|
|
ink_pixels = int(cropped_ink.sum())
|
|
boundary_pixels = int(boundary.sum())
|
|
inside_pixels = int(largest_inside_pixels)
|
|
|
|
pixel_to_mm = POINT_TO_MM / zoom / scale_ratio
|
|
pixel_area_mm2 = pixel_to_mm ** 2
|
|
|
|
ink_area_mm2 = ink_pixels * pixel_area_mm2
|
|
flood_area_mm2 = inside_pixels * pixel_area_mm2
|
|
|
|
crop_h, crop_w = cropped_ink.shape
|
|
crop_area_pixels = crop_w * crop_h
|
|
|
|
flood_ratio = inside_pixels / crop_area_pixels if crop_area_pixels else 0
|
|
ink_ratio = ink_pixels / crop_area_pixels if crop_area_pixels else 0
|
|
|
|
# Decision logic:
|
|
# - If flood-fill finds a plausible closed section, use it.
|
|
# - If the ROI contains a filled/hatch mass and flood-fill is not useful, use ink area.
|
|
# - Never trust a flood area that almost fills the whole ROI crop.
|
|
selected_method = None
|
|
selected_area_mm2 = None
|
|
|
|
if mode in ("auto_roi", "stitch_contour", "closed_path"):
|
|
if flood_area_mm2 > max(ink_area_mm2 * 2.0, 5.0) and flood_ratio < 0.92:
|
|
selected_method = "raster_flood_fill"
|
|
selected_area_mm2 = flood_area_mm2
|
|
|
|
if selected_area_mm2 is None and mode in ("auto_roi", "filled_union"):
|
|
if ink_area_mm2 > 1.0:
|
|
selected_method = "raster_filled_ink"
|
|
selected_area_mm2 = ink_area_mm2
|
|
|
|
if selected_area_mm2 is None:
|
|
return None, "ROI analizzata, ma non è stata trovata un'area raster plausibile."
|
|
|
|
# Estimate width / height of detected ink bounding box.
|
|
width_mm = crop_w * pixel_to_mm
|
|
height_mm = crop_h * pixel_to_mm
|
|
|
|
diagnostics = {
|
|
"render_zoom": zoom,
|
|
"roi_rect_points": {
|
|
"x0": roi_rect.x0,
|
|
"y0": roi_rect.y0,
|
|
"x1": roi_rect.x1,
|
|
"y1": roi_rect.y1,
|
|
},
|
|
"render_width_px": width,
|
|
"render_height_px": height,
|
|
"ink_pixels": ink_pixels,
|
|
"boundary_pixels": boundary_pixels,
|
|
"inside_pixels": inside_pixels,
|
|
"ink_area_mm2": round(ink_area_mm2, 4),
|
|
"flood_area_mm2": round(flood_area_mm2, 4),
|
|
"ink_ratio": round(ink_ratio, 4),
|
|
"flood_ratio": round(flood_ratio, 4),
|
|
"selected_method": selected_method,
|
|
"crop_width_px": crop_w,
|
|
"crop_height_px": crop_h,
|
|
}
|
|
|
|
return {
|
|
"area_mm2": selected_area_mm2,
|
|
"width_mm": round(width_mm, 3),
|
|
"height_mm": round(height_mm, 3),
|
|
"method": selected_method,
|
|
"diagnostics": diagnostics,
|
|
}, None
|
|
|
|
|
|
def _extract_points_from_drawing(drawing):
|
|
points = []
|
|
source_type = "path"
|
|
|
|
for item in drawing.get("items", []):
|
|
cmd = item[0]
|
|
|
|
if cmd == "re":
|
|
rect = item[1]
|
|
source_type = "rectangle"
|
|
|
|
points = [
|
|
(float(rect.x0), float(rect.y0)),
|
|
(float(rect.x1), float(rect.y0)),
|
|
(float(rect.x1), float(rect.y1)),
|
|
(float(rect.x0), float(rect.y1)),
|
|
(float(rect.x0), float(rect.y0)),
|
|
]
|
|
|
|
return points, source_type
|
|
|
|
if cmd == "l":
|
|
p1 = _pt(item[1])
|
|
p2 = _pt(item[2])
|
|
|
|
if not points:
|
|
points.append(p1)
|
|
|
|
if _dist(points[-1], p1) > 0.01:
|
|
points.append(p1)
|
|
|
|
points.append(p2)
|
|
|
|
elif cmd == "c" and len(item) >= 5:
|
|
p0 = _pt(item[1])
|
|
c1 = _pt(item[2])
|
|
c2 = _pt(item[3])
|
|
p3 = _pt(item[4])
|
|
|
|
if not points:
|
|
points.append(p0)
|
|
elif _dist(points[-1], p0) > 0.01:
|
|
points.append(p0)
|
|
|
|
points.extend(_cubic_bezier(p0, c1, c2, p3, steps=24))
|
|
|
|
return points, source_type
|
|
|
|
|
|
def _vector_closed_path_area(drawings, scale_ratio):
|
|
candidates = []
|
|
|
|
for index, drawing in enumerate(drawings):
|
|
points, source_type = _extract_points_from_drawing(drawing)
|
|
|
|
if source_type == "rectangle":
|
|
continue
|
|
|
|
if len(points) < 6:
|
|
continue
|
|
|
|
if _dist(points[0], points[-1]) > 1.5:
|
|
continue
|
|
|
|
polygon = _safe_polygon(points)
|
|
|
|
if polygon is None:
|
|
continue
|
|
|
|
area_mm2 = _area_mm2_from_polygon(polygon, scale_ratio)
|
|
|
|
if area_mm2 < 5:
|
|
continue
|
|
|
|
width_mm, height_mm = _bounds_mm_from_polygon(polygon, scale_ratio)
|
|
|
|
min_side = min(width_mm, height_mm)
|
|
max_side = max(width_mm, height_mm)
|
|
|
|
if min_side <= 0:
|
|
continue
|
|
|
|
aspect = max_side / min_side
|
|
|
|
if aspect > MAX_ASPECT_RATIO:
|
|
continue
|
|
|
|
candidates.append({
|
|
"drawing_index": index,
|
|
"area_mm2": area_mm2,
|
|
"width_mm": width_mm,
|
|
"height_mm": height_mm,
|
|
"points_count": len(points),
|
|
"aspect_ratio": round(aspect, 3),
|
|
})
|
|
|
|
if not candidates:
|
|
return None, "Nessun path vettoriale chiuso plausibile trovato."
|
|
|
|
candidates.sort(key=lambda x: x["area_mm2"], reverse=True)
|
|
best = candidates[0]
|
|
|
|
return {
|
|
"area_mm2": best["area_mm2"],
|
|
"width_mm": best["width_mm"],
|
|
"height_mm": best["height_mm"],
|
|
"method": "vector_closed_path",
|
|
"diagnostics": {
|
|
"candidates_count": len(candidates),
|
|
"selected_candidate": best,
|
|
"candidates_preview": candidates[:20],
|
|
},
|
|
}, None
|
|
|
|
|
|
def calculate_pdf_vector_area(
|
|
pdf_bytes,
|
|
filename="uploaded.pdf",
|
|
scale_ratio=None,
|
|
profile_color=None,
|
|
roi=None,
|
|
mode="auto_roi",
|
|
):
|
|
try:
|
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
|
except Exception as exc:
|
|
return {
|
|
"success": False,
|
|
"message": f"Impossibile aprire il PDF: {exc}",
|
|
}
|
|
|
|
if len(doc) == 0:
|
|
return {
|
|
"success": False,
|
|
"message": "Il PDF non ha pagine.",
|
|
}
|
|
|
|
page_index = 0
|
|
|
|
if roi and roi.get("page"):
|
|
try:
|
|
page_index = max(0, int(roi.get("page")) - 1)
|
|
except Exception:
|
|
page_index = 0
|
|
|
|
if page_index >= len(doc):
|
|
page_index = 0
|
|
|
|
page = doc[page_index]
|
|
all_drawings = page.get_drawings()
|
|
|
|
if scale_ratio is None:
|
|
detected_scale = _detect_scale_from_text(page)
|
|
|
|
if detected_scale is not None:
|
|
scale_ratio = detected_scale
|
|
scale_source = "text_detected"
|
|
else:
|
|
scale_ratio = 1.0
|
|
scale_source = "default_1:1"
|
|
else:
|
|
try:
|
|
scale_ratio = float(scale_ratio)
|
|
except Exception:
|
|
scale_ratio = 1.0
|
|
|
|
if scale_ratio <= 0:
|
|
scale_ratio = 1.0
|
|
|
|
scale_source = "manual"
|
|
|
|
roi_rect = _normalized_roi_to_page_rect(page.rect, roi)
|
|
drawings = _filter_drawings_by_roi(all_drawings, roi_rect)
|
|
|
|
diagnostics = {
|
|
"filename": filename,
|
|
"total_pages": len(doc),
|
|
"page_index_used": page_index,
|
|
"page_width_mm": round(page.rect.width * POINT_TO_MM / scale_ratio, 3),
|
|
"page_height_mm": round(page.rect.height * POINT_TO_MM / scale_ratio, 3),
|
|
"scale_ratio": scale_ratio,
|
|
"scale_source": scale_source,
|
|
"mode": mode,
|
|
"roi_used": roi_rect is not None,
|
|
"roi": roi,
|
|
"total_drawings_page": len(all_drawings),
|
|
"drawings_inside_roi": len(drawings),
|
|
}
|
|
|
|
# First choice: ROI raster method.
|
|
# This is safer for exploded CAD linework because it measures the selected section image.
|
|
if roi_rect is not None and mode in ("auto_roi", "stitch_contour", "filled_union", "closed_path"):
|
|
raster_result, raster_error = _raster_roi_area(
|
|
page=page,
|
|
roi_rect=roi_rect,
|
|
scale_ratio=scale_ratio,
|
|
mode=mode
|
|
)
|
|
|
|
if raster_result is not None:
|
|
area_mm2 = round(float(raster_result["area_mm2"]), 4)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Area calcolata sulla ROI selezionata.",
|
|
"area_mm2": area_mm2,
|
|
"area_cm2": round(area_mm2 / 100.0, 6),
|
|
"area_m2": round(area_mm2 / 1_000_000.0, 9),
|
|
"width_mm": raster_result["width_mm"],
|
|
"height_mm": raster_result["height_mm"],
|
|
"scale_detected": f"{scale_ratio}:1",
|
|
"scale_used": scale_ratio,
|
|
"scale_source": scale_source,
|
|
"strategy_used": raster_result["method"],
|
|
"confidence": "needs_validation",
|
|
"diagnostics": {
|
|
**diagnostics,
|
|
"raster": raster_result["diagnostics"],
|
|
},
|
|
}
|
|
|
|
diagnostics["raster_error"] = raster_error
|
|
|
|
# Fallback: closed vector path only.
|
|
vector_result, vector_error = _vector_closed_path_area(drawings, scale_ratio)
|
|
|
|
if vector_result is not None:
|
|
area_mm2 = round(float(vector_result["area_mm2"]), 4)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Area calcolata da path vettoriale chiuso.",
|
|
"area_mm2": area_mm2,
|
|
"area_cm2": round(area_mm2 / 100.0, 6),
|
|
"area_m2": round(area_mm2 / 1_000_000.0, 9),
|
|
"width_mm": vector_result["width_mm"],
|
|
"height_mm": vector_result["height_mm"],
|
|
"scale_detected": f"{scale_ratio}:1",
|
|
"scale_used": scale_ratio,
|
|
"scale_source": scale_source,
|
|
"strategy_used": vector_result["method"],
|
|
"confidence": "needs_validation",
|
|
"diagnostics": {
|
|
**diagnostics,
|
|
"vector": vector_result["diagnostics"],
|
|
},
|
|
}
|
|
|
|
diagnostics["vector_error"] = vector_error
|
|
|
|
return {
|
|
"success": False,
|
|
"message": (
|
|
"Nessuna area affidabile trovata. "
|
|
"Definisci una ROI più stretta intorno alla sola sezione del profilo, "
|
|
"oppure verifica la scala del disegno."
|
|
),
|
|
"area_mm2": None,
|
|
"area_cm2": None,
|
|
"area_m2": None,
|
|
"scale_used": scale_ratio,
|
|
"scale_source": scale_source,
|
|
"strategy_used": None,
|
|
"confidence": "low",
|
|
"diagnostics": diagnostics,
|
|
} |