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