Files
zibo-dashboard/python-cad-area/cad_vector_area.py
T
2026-06-16 09:23:40 +02:00

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