| import gradio as gr |
| import cv2 |
| import numpy as np |
| from PIL import Image |
| from sklearn.cluster import DBSCAN |
| from scipy.spatial import ConvexHull |
| import warnings |
| warnings.filterwarnings("ignore") |
|
|
|
|
| |
| |
| |
|
|
| def preprocess_image(img_rgb: np.ndarray) -> list[np.ndarray]: |
| """ |
| Generate multiple preprocessed versions to maximize detection coverage |
| across different lighting, contrast, and color conditions. |
| """ |
| h, w = img_rgb.shape[:2] |
| long_side = max(h, w) |
| scale = 1.0 |
| if long_side > 1200: |
| scale = 1200 / long_side |
| img_rgb = cv2.resize(img_rgb, (int(w * scale), int(h * scale))) |
|
|
| gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) |
| variants = [] |
|
|
| |
| clahe = cv2.createCLAHE(clipLimit=9.0, tileGridSize=(8, 8)) |
| clahe_gray = clahe.apply(gray) |
| variants.append(clahe_gray) |
|
|
| |
| blurred = cv2.GaussianBlur(gray, (5, 5), 1.5) |
| variants.append(blurred) |
|
|
| |
| bilateral = cv2.bilateralFilter(gray, 9, 75, 75) |
| variants.append(bilateral) |
|
|
| |
| equalized = cv2.equalizeHist(gray) |
| variants.append(equalized) |
|
|
| |
| variants.append(cv2.bitwise_not(clahe_gray)) |
|
|
| return variants, img_rgb, scale |
|
|
|
|
| def hough_multi_params(gray: np.ndarray, img_shape: tuple) -> np.ndarray: |
| """ |
| Run HoughCircles across a sweep of parameters. |
| Radius bounds are computed as a fraction of image dimensions. |
| """ |
| h, w = img_shape[:2] |
| short_side = min(h, w) |
|
|
| |
| r_min = max(8, int(short_side * 0.012)) |
| r_max = max(40, int(short_side * 0.09)) |
|
|
| param_grid = [ |
| dict(dp=1.0, minDist=int(short_side*0.04), param1=80, param2=28, minRadius=r_min, maxRadius=r_max), |
| dict(dp=1.2, minDist=int(short_side*0.04), param1=60, param2=22, minRadius=r_min, maxRadius=r_max), |
| dict(dp=1.5, minDist=int(short_side*0.05), param1=100, param2=35, minRadius=r_min, maxRadius=r_max), |
| dict(dp=1.0, minDist=int(short_side*0.03), param1=50, param2=18, minRadius=r_min, maxRadius=r_max), |
| dict(dp=2.0, minDist=int(short_side*0.05), param1=70, param2=25, minRadius=r_min, maxRadius=r_max), |
| ] |
|
|
| all_circles = [] |
| for p in param_grid: |
| c = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, **p) |
| if c is not None: |
| all_circles.extend(np.round(c[0]).astype(int).tolist()) |
|
|
| return np.array(all_circles) if all_circles else np.empty((0, 3), dtype=int) |
|
|
|
|
| def contour_circle_fallback(gray: np.ndarray) -> np.ndarray: |
| """ |
| Contour-based circle detector as a fallback. |
| Finds circular blobs via adaptive thresholding + circularity filter. |
| """ |
| h, w = gray.shape |
| short = min(h, w) |
| r_min = int(short * 0.012) |
| r_max = int(short * 0.09) |
|
|
| thresh = cv2.adaptiveThreshold( |
| gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 4 |
| ) |
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) |
| thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel, iterations=2) |
|
|
| contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
| circles = [] |
| for cnt in contours: |
| area = cv2.contourArea(cnt) |
| perim = cv2.arcLength(cnt, True) |
| if perim == 0: |
| continue |
| circularity = 4 * np.pi * area / (perim ** 2) |
| if circularity < 0.55: |
| continue |
| (cx, cy), radius = cv2.minEnclosingCircle(cnt) |
| if r_min <= int(radius) <= r_max: |
| circles.append([int(cx), int(cy), int(radius)]) |
|
|
| return np.array(circles) if circles else np.empty((0, 3), dtype=int) |
|
|
|
|
| def deduplicate_circles(circles: np.ndarray, tol_frac: float = 0.04, img_shape=None) -> np.ndarray: |
| """ |
| Remove duplicate/overlapping detections using NMS-style spatial dedup. |
| Tolerance is a fraction of the shorter image dimension. |
| """ |
| if len(circles) == 0: |
| return circles |
| h, w = img_shape[:2] |
| tol = int(min(h, w) * tol_frac) |
| circles = circles[np.argsort(circles[:, 2])[::-1]] |
| kept = [] |
| for c in circles: |
| if all(np.linalg.norm(c[:2] - k[:2]) > tol for k in kept): |
| kept.append(c) |
| return np.array(kept) |
|
|
|
|
| |
| |
| |
|
|
| def detect_orientation(points: np.ndarray) -> float: |
| """ |
| PCA on hole centres β returns dominant axis angle in degrees. |
| Works regardless of camera rotation. |
| """ |
| if len(points) < 2: |
| return 0.0 |
| centered = points - points.mean(axis=0) |
| _, _, vt = np.linalg.svd(centered) |
| angle = np.degrees(np.arctan2(vt[0, 1], vt[0, 0])) |
| return angle |
|
|
|
|
| def cluster_holes(circles: np.ndarray, img_shape: tuple): |
| """ |
| DBSCAN clustering along the perpendicular axis to the detected orientation. |
| Returns dict of cluster_id β list of (x, y). |
| Falls back to 2-cluster top/bottom split if DBSCAN finds only 1 cluster. |
| """ |
| pts = circles[:, :2].astype(float) |
| angle = detect_orientation(pts) |
|
|
| |
| angle_rad = np.radians(angle) |
| perp = np.array([-np.sin(angle_rad), np.cos(angle_rad)]) |
| proj = pts @ perp |
|
|
| h, w = img_shape[:2] |
| eps = min(h, w) * 0.08 |
| labels = DBSCAN(eps=eps, min_samples=1).fit_predict(proj.reshape(-1, 1)) |
|
|
| clusters = {} |
| for lbl, pt in zip(labels, circles): |
| clusters.setdefault(int(lbl), []).append((int(pt[0]), int(pt[1]))) |
|
|
| |
| medians = {k: np.median([pts[i] @ perp for i, l in enumerate(labels) if l == k]) |
| for k in clusters} |
| sorted_keys = sorted(clusters.keys(), key=lambda k: medians[k]) |
| ordered = {i: clusters[k] for i, k in enumerate(sorted_keys)} |
|
|
| return ordered, angle |
|
|
|
|
| |
| |
| |
|
|
| def build_bbox(all_pts: list, extend_px: int, img_shape: tuple): |
| all_x = [p[0] for p in all_pts] |
| all_y = [p[1] for p in all_pts] |
| h, w = img_shape[:2] |
| x1 = max(0, min(all_x) - extend_px) |
| y1 = max(0, min(all_y) - extend_px) |
| x2 = min(w, max(all_x) + extend_px) |
| y2 = min(h, max(all_y) + extend_px) |
| return x1, y1, x2, y2 |
|
|
|
|
| |
| |
| |
|
|
| PALETTE = [ |
| (0, 230, 80), |
| (255, 180, 0), |
| (0, 200, 255), |
| (255, 80, 200), |
| (180, 255, 80), |
| (255, 140, 60), |
| ] |
|
|
| def draw_results(vis: np.ndarray, circles: np.ndarray, clusters: dict, |
| angle: float, extend_px: int): |
| h, w = vis.shape[:2] |
| all_pts = [] |
|
|
| for row_idx, pts in clusters.items(): |
| color = PALETTE[row_idx % len(PALETTE)] |
| pts_sorted = sorted(pts, key=lambda p: p[0]) |
| all_pts.extend(pts) |
|
|
| |
| if len(pts_sorted) >= 2: |
| cv2.line(vis, pts_sorted[0], pts_sorted[-1], color, 2, cv2.LINE_AA) |
|
|
| for x, y in pts: |
| r_draw = 22 |
| cv2.circle(vis, (x, y), r_draw, color, 3, cv2.LINE_AA) |
| cv2.circle(vis, (x, y), 5, (255, 60, 60), -1, cv2.LINE_AA) |
|
|
| if not all_pts: |
| return vis, None |
|
|
| x1, y1, x2, y2 = build_bbox(all_pts, extend_px, vis.shape) |
|
|
| |
| cv2.rectangle(vis, (x1, y1), (x2, y2), (255, 80, 80), 3, cv2.LINE_AA) |
|
|
| |
| cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 |
| arrow_len = 50 |
| ang = np.radians(angle) |
| ex, ey = int(cx + arrow_len * np.cos(ang)), int(cy + arrow_len * np.sin(ang)) |
| cv2.arrowedLine(vis, (cx, cy), (ex, ey), (255, 255, 255), 2, |
| cv2.LINE_AA, tipLength=0.3) |
|
|
| |
| font, fs, thick = cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1 |
| for row_idx, pts in clusters.items(): |
| c = PALETTE[row_idx % len(PALETTE)] |
| label = f"Row {row_idx+1}: {len(pts)} holes" |
| tx = x1 + 6 |
| ty = y1 + 22 + row_idx * 22 |
| ty = min(ty, h - 8) |
| cv2.putText(vis, label, (tx, ty), font, fs, (0,0,0), thick+2, cv2.LINE_AA) |
| cv2.putText(vis, label, (tx, ty), font, fs, c, thick, cv2.LINE_AA) |
|
|
| return vis, (x1, y1, x2, y2) |
|
|
|
|
| |
| |
| |
|
|
| def detect_bolt_holes(image, extend_px: int = 40, min_holes: int = 2): |
| """ |
| Robust, rotation-invariant bolt-hole localizer. |
| |
| Pipeline: |
| 1. Multi-variant preprocessing (CLAHE, bilateral, equalized, inverted) |
| 2. Hough + contour-based detection across parameter sweeps |
| 3. Aggregate and deduplicate across all attempts |
| 4. PCA-based orientation detection |
| 5. DBSCAN clustering along perpendicular axis |
| 6. Draw bounding box + per-row annotations |
| 7. Crop and return ROI |
| """ |
| if image is None: |
| return None, None, "β οΈ No image provided." |
|
|
| img_rgb = np.array(image) |
| if img_rgb.ndim == 2: |
| img_rgb = cv2.cvtColor(img_rgb, cv2.COLOR_GRAY2RGB) |
| elif img_rgb.shape[2] == 4: |
| img_rgb = cv2.cvtColor(img_rgb, cv2.COLOR_RGBA2RGB) |
|
|
| variants, img_rgb, scale = preprocess_image(img_rgb) |
|
|
| |
| raw_circles = [] |
| for gray_variant in variants: |
| c = hough_multi_params(gray_variant, img_rgb.shape) |
| if len(c): |
| raw_circles.extend(c.tolist()) |
| c2 = contour_circle_fallback(gray_variant) |
| if len(c2): |
| raw_circles.extend(c2.tolist()) |
|
|
| if not raw_circles: |
| return Image.fromarray(img_rgb), Image.fromarray(img_rgb), \ |
| "β No circles detected. Try a closer or clearer image." |
|
|
| circles = deduplicate_circles(np.array(raw_circles), img_shape=img_rgb.shape) |
|
|
| if len(circles) < min_holes: |
| return Image.fromarray(img_rgb), Image.fromarray(img_rgb), \ |
| f"β οΈ Only {len(circles)} unique circles found (need β₯{min_holes})." |
|
|
| |
| clusters, angle = cluster_holes(circles, img_rgb.shape) |
|
|
| |
| vis = img_rgb.copy() |
| vis, bbox = draw_results(vis, circles, clusters, angle, extend_px) |
|
|
| |
| if bbox: |
| x1, y1, x2, y2 = bbox |
| cropped = img_rgb[y1:y2, x1:x2] |
| else: |
| cropped = img_rgb |
|
|
| |
| total = sum(len(v) for v in clusters.values()) |
| rows_info = "\n".join( |
| f" β’ Row {i+1} ({len(pts)} holes): {pts}" |
| for i, pts in clusters.items() |
| ) |
| stats = ( |
| f"β
**{total} bolt holes detected** across **{len(clusters)} row(s)**\n" |
| f"π Dominant orientation: **{angle:.1f}Β°**\n" |
| f"{rows_info}\n" |
| + (f"π¦ Bounding Box: ({x1},{y1}) β ({x2},{y2}) | " |
| f"ROI: {x2-x1}Γ{y2-y1} px" if bbox else "") |
| ) |
|
|
| return Image.fromarray(vis), Image.fromarray(cropped), stats |
|
|
|
|
| |
| |
| |
|
|
| CSS = """ |
| body { font-family: 'Courier New', monospace; background: #0e0e0e; color: #e8e8e8; } |
| .gradio-container { max-width: 1200px; margin: 0 auto; } |
| .title-bar { text-align: center; padding: 18px 0 4px; } |
| .title-bar h1 { font-size: 1.7rem; letter-spacing: 3px; color: #00e5a0; margin: 0; } |
| .title-bar p { color: #888; font-size: 0.85rem; margin: 4px 0 0; } |
| footer { display: none !important; } |
| """ |
|
|
| with gr.Blocks( |
| title="Bolt Hole Localizer v2 β Rotation-Invariant", |
| theme=gr.themes.Base( |
| primary_hue="green", |
| neutral_hue="gray", |
| font=[gr.themes.GoogleFont("Space Mono"), "monospace"], |
| ), |
| css=CSS, |
| ) as demo: |
|
|
| gr.HTML(""" |
| <div class="title-bar"> |
| <h1>π© BOLT HOLE LOCALIZER v2</h1> |
| <p>Rotation-Invariant Β· Multi-Scale Β· DBSCAN Clustering Β· Any Engine Configuration</p> |
| </div> |
| """) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| inp_img = gr.Image(type="pil", label="π· Input Engine Image", |
| sources=["upload", "clipboard"]) |
|
|
| with gr.Accordion("βοΈ Advanced Settings", open=False): |
| extend_slider = gr.Slider( |
| 10, 120, value=40, step=5, |
| label="Bounding Box Margin (px)", |
| info="Extra padding around detected bolt cluster" |
| ) |
| min_holes_slider = gr.Slider( |
| 2, 8, value=2, step=1, |
| label="Minimum Holes Required", |
| info="Reject results below this count" |
| ) |
|
|
| run_btn = gr.Button("π Detect Bolt Holes", variant="primary", size="lg") |
|
|
| with gr.Column(scale=2): |
| with gr.Row(): |
| out_annotated = gr.Image(label="π Annotated β Holes + Orientation + BBox") |
| out_cropped = gr.Image(label="βοΈ Cropped ROI") |
| out_stats = gr.Markdown(label="Detection Stats") |
|
|
| run_btn.click( |
| fn=detect_bolt_holes, |
| inputs=[inp_img, extend_slider, min_holes_slider], |
| outputs=[out_annotated, out_cropped, out_stats], |
| ) |
|
|
| gr.Markdown(""" |
| --- |
| **Detection Strategy:** |
| Multi-variant preprocessing (CLAHE Β· bilateral Β· equalized Β· inverted) β |
| Hough + contour sweep β |
| Spatial deduplication β |
| PCA orientation β |
| DBSCAN row clustering β |
| Rotation-invariant bounding box |
| """) |
|
|
| if __name__ == "__main__": |
| demo.launch() |