line cropping
Browse files
app.py
CHANGED
|
@@ -1,133 +1,313 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import cv2
|
| 3 |
import numpy as np
|
| 4 |
-
|
| 5 |
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
def
|
| 8 |
"""
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
2. Cluster into top-row and bottom-row (2 horizontal bands)
|
| 12 |
-
3. Draw horizontal connector lines across each row
|
| 13 |
-
4. Extend lines and add vertical connectors β tight bounding box
|
| 14 |
-
5. Return annotated image + cropped ROI
|
| 15 |
"""
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
minDist
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
# ββ Step 2: Cluster into 2 horizontal rows ββββββββββββββββββββββββββββββ
|
| 37 |
-
ys = [c[1] for c in circles]
|
| 38 |
-
y_median = np.median(ys)
|
| 39 |
-
top_candidates = [(x, y) for x, y, r in circles if y < y_median]
|
| 40 |
-
bot_candidates = [(x, y) for x, y, r in circles if y >= y_median]
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
return pts[:n] if len(pts) >= n else pts
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
if len(all_holes) < 4:
|
| 52 |
-
return image, image, f"β οΈ Only {len(all_holes)} holes found β need at least 4."
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
vis = img_rgb.copy()
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
# Horizontal
|
| 69 |
if len(top_row) >= 2:
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
if len(bot_row) >= 2:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
stats = (
|
| 107 |
-
f"
|
| 108 |
-
f"
|
| 109 |
-
f"
|
| 110 |
-
f"
|
| 111 |
-
f"
|
|
|
|
| 112 |
)
|
| 113 |
|
| 114 |
-
return Image.fromarray(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
# ββ Gradio UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
-
with gr.Blocks(
|
| 118 |
-
title="Bolt Hole Localizer β Engine CV",
|
| 119 |
-
theme=gr.themes.Default(primary_hue="blue"),
|
| 120 |
-
) as demo:
|
| 121 |
gr.Markdown(
|
| 122 |
"""
|
| 123 |
-
# π© Engine Bolt Hole
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
|
| 133 |
with gr.Row():
|
|
@@ -135,20 +315,18 @@ with gr.Blocks(
|
|
| 135 |
|
| 136 |
with gr.Row():
|
| 137 |
extend_slider = gr.Slider(
|
| 138 |
-
minimum=10, maximum=
|
| 139 |
-
label="Bounding Box
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
)
|
| 144 |
|
| 145 |
-
run_btn = gr.Button("π Detect Bolt Holes", variant="primary")
|
| 146 |
|
| 147 |
with gr.Row():
|
| 148 |
-
out_annotated = gr.Image(label="π Annotated
|
| 149 |
-
out_cropped = gr.Image(label="βοΈ
|
| 150 |
|
| 151 |
-
out_stats = gr.Markdown(label="Detection
|
| 152 |
|
| 153 |
run_btn.click(
|
| 154 |
fn=detect_bolt_holes,
|
|
@@ -156,10 +334,5 @@ with gr.Blocks(
|
|
| 156 |
outputs=[out_annotated, out_cropped, out_stats],
|
| 157 |
)
|
| 158 |
|
| 159 |
-
gr.Examples(
|
| 160 |
-
examples=[["perfect1.jpeg"]],
|
| 161 |
-
inputs=inp_img,
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
if __name__ == "__main__":
|
| 165 |
demo.launch()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Production-Grade Engine Bolt Hole Localizer
|
| 3 |
+
============================================
|
| 4 |
+
Senior CV Pipeline β targets 99% detection accuracy in production.
|
| 5 |
+
|
| 6 |
+
Key improvements over naive HoughCircles:
|
| 7 |
+
1. CLAHE contrast normalisation β handles dark/uneven lighting
|
| 8 |
+
2. Multi-scale Hough sweep β catches holes of varying apparent size
|
| 9 |
+
3. NMS deduplication β removes duplicate circles from multi-scale
|
| 10 |
+
4. K-means (k=2) row clustering β robust top/bottom split (not a median hack)
|
| 11 |
+
5. Intra-row outlier rejection β drops spurious detections via IQR on y
|
| 12 |
+
6. Best-N selection per row β always attempts 4 per row, warns if short
|
| 13 |
+
7. Least-squares horizontal fit β straight line through row centroids (not
|
| 14 |
+
just first-to-last point join)
|
| 15 |
+
8. Tight crop with configurable β returned as separate PIL image
|
| 16 |
+
padding
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
import cv2
|
| 20 |
import numpy as np
|
| 21 |
+
import gradio as gr
|
| 22 |
from PIL import Image
|
| 23 |
+
from sklearn.cluster import KMeans
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
# CORE DETECTION ENGINE
|
| 28 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
def _clahe_preprocess(gray: np.ndarray) -> np.ndarray:
|
| 31 |
+
"""CLAHE + mild Gaussian blur β maximises circle edge contrast."""
|
| 32 |
+
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
| 33 |
+
enhanced = clahe.apply(gray)
|
| 34 |
+
blurred = cv2.GaussianBlur(enhanced, (5, 5), 1.2)
|
| 35 |
+
return blurred
|
| 36 |
+
|
| 37 |
|
| 38 |
+
def _multi_scale_hough(gray_proc: np.ndarray) -> np.ndarray:
|
| 39 |
"""
|
| 40 |
+
Run HoughCircles across a parameter grid and merge all candidates.
|
| 41 |
+
Returns array of shape (N, 3): [cx, cy, radius].
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
"""
|
| 43 |
+
h, w = gray_proc.shape
|
| 44 |
+
# Scale minRadius / maxRadius relative to image size so the detector
|
| 45 |
+
# generalises to different resolutions.
|
| 46 |
+
short_side = min(h, w)
|
| 47 |
+
min_r = max(8, int(short_side * 0.012))
|
| 48 |
+
max_r = min(80, int(short_side * 0.075))
|
| 49 |
+
|
| 50 |
+
param_grid = [
|
| 51 |
+
# (dp, minDist, p1, p2)
|
| 52 |
+
(1.0, 25, 100, 28),
|
| 53 |
+
(1.0, 25, 80, 22),
|
| 54 |
+
(1.2, 28, 60, 18),
|
| 55 |
+
(1.5, 20, 50, 15),
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
all_circles = []
|
| 59 |
+
for dp, min_dist, p1, p2 in param_grid:
|
| 60 |
+
c = cv2.HoughCircles(
|
| 61 |
+
gray_proc,
|
| 62 |
+
cv2.HOUGH_GRADIENT,
|
| 63 |
+
dp=dp,
|
| 64 |
+
minDist=min_dist,
|
| 65 |
+
param1=p1,
|
| 66 |
+
param2=p2,
|
| 67 |
+
minRadius=min_r,
|
| 68 |
+
maxRadius=max_r,
|
| 69 |
+
)
|
| 70 |
+
if c is not None:
|
| 71 |
+
all_circles.extend(c[0].tolist())
|
| 72 |
+
|
| 73 |
+
if not all_circles:
|
| 74 |
+
return np.empty((0, 3))
|
| 75 |
|
| 76 |
+
return np.array(all_circles, dtype=np.float32)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _nms_circles(circles: np.ndarray, iou_thresh: float = 0.35) -> np.ndarray:
|
| 80 |
+
"""
|
| 81 |
+
Non-Maximum Suppression for circles.
|
| 82 |
+
Merges overlapping detections by keeping the one with the largest radius
|
| 83 |
+
(proxy for detection confidence) and discarding neighbours whose centre
|
| 84 |
+
distance < (r1 + r2) * iou_thresh.
|
| 85 |
+
"""
|
| 86 |
+
if len(circles) == 0:
|
| 87 |
+
return circles
|
| 88 |
|
| 89 |
+
# Sort descending by radius (largest first β keep)
|
| 90 |
+
order = np.argsort(-circles[:, 2])
|
| 91 |
+
keep = []
|
| 92 |
+
used = np.zeros(len(circles), dtype=bool)
|
| 93 |
+
|
| 94 |
+
for i in order:
|
| 95 |
+
if used[i]:
|
| 96 |
+
continue
|
| 97 |
+
keep.append(i)
|
| 98 |
+
cx, cy, cr = circles[i]
|
| 99 |
+
for j in order:
|
| 100 |
+
if used[j] or j == i:
|
| 101 |
+
continue
|
| 102 |
+
ox, oy, or_ = circles[j]
|
| 103 |
+
dist = np.hypot(cx - ox, cy - oy)
|
| 104 |
+
if dist < (cr + or_) * (1.0 - iou_thresh):
|
| 105 |
+
used[j] = True
|
| 106 |
+
|
| 107 |
+
return circles[keep]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _cluster_rows(circles: np.ndarray, n_rows: int = 2) -> tuple:
|
| 111 |
+
"""
|
| 112 |
+
K-means on y-coordinate οΏ½οΏ½οΏ½ robust top/bottom row assignment.
|
| 113 |
+
Returns (top_circles, bot_circles) each as (N,3) arrays.
|
| 114 |
+
"""
|
| 115 |
+
if len(circles) < n_rows:
|
| 116 |
+
return circles, np.empty((0, 3))
|
| 117 |
+
|
| 118 |
+
ys = circles[:, 1].reshape(-1, 1)
|
| 119 |
+
km = KMeans(n_clusters=n_rows, n_init=10, random_state=42).fit(ys)
|
| 120 |
+
labels = km.labels_
|
| 121 |
+
|
| 122 |
+
# Identify which label is top (smaller y) vs bottom
|
| 123 |
+
centres_y = [ys[labels == k].mean() for k in range(n_rows)]
|
| 124 |
+
top_label = int(np.argmin(centres_y))
|
| 125 |
+
bot_label = 1 - top_label
|
| 126 |
+
|
| 127 |
+
top = circles[labels == top_label]
|
| 128 |
+
bot = circles[labels == bot_label]
|
| 129 |
+
return top, bot
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _reject_outliers_iqr(row: np.ndarray, axis: int = 1) -> np.ndarray:
|
| 133 |
+
"""Drop points whose y-value is an outlier within their own row (IQR rule)."""
|
| 134 |
+
if len(row) < 3:
|
| 135 |
+
return row
|
| 136 |
+
vals = row[:, axis]
|
| 137 |
+
q1, q3 = np.percentile(vals, 25), np.percentile(vals, 75)
|
| 138 |
+
iqr = q3 - q1
|
| 139 |
+
mask = (vals >= q1 - 1.5 * iqr) & (vals <= q3 + 1.5 * iqr)
|
| 140 |
+
return row[mask]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _best_n(row: np.ndarray, n: int = 4) -> np.ndarray:
|
| 144 |
+
"""Return up to n circles from a row, sorted left-to-right by x."""
|
| 145 |
+
row_sorted = row[np.argsort(row[:, 0])]
|
| 146 |
+
return row_sorted[:n]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _fit_horizontal_line(points: np.ndarray) -> float:
|
| 150 |
+
"""
|
| 151 |
+
Least-squares horizontal fit: returns the mean y of the row.
|
| 152 |
+
(For bolt holes in a flat pattern this is the correct model.)
|
| 153 |
+
"""
|
| 154 |
+
return float(np.mean(points[:, 1]))
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 158 |
+
# VISUALISATION
|
| 159 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 160 |
|
| 161 |
+
PALETTE = {
|
| 162 |
+
"hole_ring" : (255, 220, 0), # yellow
|
| 163 |
+
"hole_dot" : (255, 60, 60), # red
|
| 164 |
+
"h_line" : ( 0, 230, 80), # green β horizontal row lines
|
| 165 |
+
"bbox" : (255, 80, 80), # red β bounding box
|
| 166 |
+
"label_bg" : ( 30, 30, 30),
|
| 167 |
+
"label_fg" : (255, 255, 255),
|
| 168 |
+
}
|
| 169 |
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
def _draw_annotations(
|
| 172 |
+
img_rgb : np.ndarray,
|
| 173 |
+
top_row : np.ndarray,
|
| 174 |
+
bot_row : np.ndarray,
|
| 175 |
+
extend_px: int,
|
| 176 |
+
) -> tuple:
|
| 177 |
+
"""
|
| 178 |
+
Draw:
|
| 179 |
+
β’ Yellow circle rings + red centre dots on each hole
|
| 180 |
+
β’ ONE straight green horizontal line per row (fitted, not endpoint-joined)
|
| 181 |
+
β’ Red bounding rectangle
|
| 182 |
+
|
| 183 |
+
Returns (annotated_img, (x1,y1,x2,y2) crop coords).
|
| 184 |
+
"""
|
| 185 |
vis = img_rgb.copy()
|
| 186 |
+
h, w = vis.shape[:2]
|
| 187 |
+
|
| 188 |
+
all_holes = np.vstack([top_row, bot_row]) if len(bot_row) else top_row
|
| 189 |
+
|
| 190 |
+
# ββ Bounding box ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 191 |
+
all_x = all_holes[:, 0]
|
| 192 |
+
all_y = all_holes[:, 1]
|
| 193 |
+
x1 = max(0, int(all_x.min()) - extend_px)
|
| 194 |
+
y1 = max(0, int(all_y.min()) - extend_px)
|
| 195 |
+
x2 = min(w, int(all_x.max()) + extend_px)
|
| 196 |
+
y2 = min(h, int(all_y.max()) + extend_px)
|
| 197 |
+
|
| 198 |
+
# ββ Horizontal lines (drawn first so holes render on top) βββββββββββββββββ
|
| 199 |
if len(top_row) >= 2:
|
| 200 |
+
y_top = int(_fit_horizontal_line(top_row))
|
| 201 |
+
cv2.line(vis, (x1, y_top), (x2, y_top), PALETTE["h_line"], 3, cv2.LINE_AA)
|
| 202 |
+
|
| 203 |
if len(bot_row) >= 2:
|
| 204 |
+
y_bot = int(_fit_horizontal_line(bot_row))
|
| 205 |
+
cv2.line(vis, (x1, y_bot), (x2, y_bot), PALETTE["h_line"], 3, cv2.LINE_AA)
|
| 206 |
+
|
| 207 |
+
# ββ Bounding box ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
cv2.rectangle(vis, (x1, y1), (x2, y2), PALETTE["bbox"], 3, cv2.LINE_AA)
|
| 209 |
+
|
| 210 |
+
# ββ Hole markers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 211 |
+
for i, (row, label) in enumerate([(top_row, "T"), (bot_row, "B")]):
|
| 212 |
+
for j, (cx, cy, cr) in enumerate(row):
|
| 213 |
+
cx, cy, cr = int(cx), int(cy), max(int(cr), 14)
|
| 214 |
+
cv2.circle(vis, (cx, cy), cr + 4, PALETTE["hole_ring"], 3, cv2.LINE_AA)
|
| 215 |
+
cv2.circle(vis, (cx, cy), 5, PALETTE["hole_dot"], -1)
|
| 216 |
+
# Small label
|
| 217 |
+
tag = f"{label}{j+1}"
|
| 218 |
+
(tw, th), _ = cv2.getTextSize(tag, cv2.FONT_HERSHEY_SIMPLEX, 0.45, 1)
|
| 219 |
+
tx, ty = cx - tw // 2, cy - cr - 8
|
| 220 |
+
cv2.rectangle(vis, (tx - 2, ty - th - 2), (tx + tw + 2, ty + 2),
|
| 221 |
+
PALETTE["label_bg"], -1)
|
| 222 |
+
cv2.putText(vis, tag, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX,
|
| 223 |
+
0.45, PALETTE["label_fg"], 1, cv2.LINE_AA)
|
| 224 |
+
|
| 225 |
+
return vis, (x1, y1, x2, y2)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 229 |
+
# PUBLIC API
|
| 230 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
def detect_bolt_holes(image: Image.Image, extend_px: int = 40):
|
| 233 |
+
"""
|
| 234 |
+
Full production pipeline.
|
| 235 |
+
Returns: (annotated PIL, cropped PIL, stats markdown)
|
| 236 |
+
"""
|
| 237 |
+
img_rgb = np.array(image.convert("RGB"))
|
| 238 |
+
gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
|
| 239 |
+
|
| 240 |
+
# 1. Preprocess
|
| 241 |
+
gray_proc = _clahe_preprocess(gray)
|
| 242 |
+
|
| 243 |
+
# 2. Multi-scale Hough
|
| 244 |
+
raw_circles = _multi_scale_hough(gray_proc)
|
| 245 |
+
if len(raw_circles) == 0:
|
| 246 |
+
return image, image, "β No circles detected β try a clearer engine image."
|
| 247 |
|
| 248 |
+
# 3. NMS deduplication
|
| 249 |
+
circles = _nms_circles(raw_circles)
|
| 250 |
+
|
| 251 |
+
# 4. Cluster into top / bottom rows
|
| 252 |
+
top_raw, bot_raw = _cluster_rows(circles)
|
| 253 |
+
|
| 254 |
+
# 5. Outlier rejection within each row
|
| 255 |
+
top_clean = _reject_outliers_iqr(top_raw)
|
| 256 |
+
bot_clean = _reject_outliers_iqr(bot_raw)
|
| 257 |
+
|
| 258 |
+
# 6. Pick best 4 per row
|
| 259 |
+
top_row = _best_n(top_clean, n=4)
|
| 260 |
+
bot_row = _best_n(bot_clean, n=4)
|
| 261 |
+
|
| 262 |
+
total = len(top_row) + len(bot_row)
|
| 263 |
+
if total < 4:
|
| 264 |
+
return image, image, f"β οΈ Only {total} bolt holes found β need β₯ 4."
|
| 265 |
+
|
| 266 |
+
# 7. Draw + crop
|
| 267 |
+
annotated, (x1, y1, x2, y2) = _draw_annotations(img_rgb, top_row, bot_row, extend_px)
|
| 268 |
+
cropped = img_rgb[y1:y2, x1:x2]
|
| 269 |
+
|
| 270 |
+
# 8. Stats
|
| 271 |
+
def fmt_row(row):
|
| 272 |
+
return ", ".join(f"({int(x)},{int(y)})" for x, y, _ in row)
|
| 273 |
+
|
| 274 |
+
status = "β
" if total == 8 else "β οΈ Partial"
|
| 275 |
stats = (
|
| 276 |
+
f"{status} Detected **{total}/8 bolt holes**\n\n"
|
| 277 |
+
f"**Top row** ({len(top_row)} holes): {fmt_row(top_row)}\n\n"
|
| 278 |
+
f"**Bottom row** ({len(bot_row)} holes): {fmt_row(bot_row)}\n\n"
|
| 279 |
+
f"**Bounding Box**: ({x1}, {y1}) β ({x2}, {y2}) "
|
| 280 |
+
f"[{x2-x1} Γ {y2-y1} px]\n\n"
|
| 281 |
+
f"**Raw candidates before NMS**: {len(raw_circles)} β after NMS: {len(circles)}"
|
| 282 |
)
|
| 283 |
|
| 284 |
+
return Image.fromarray(annotated), Image.fromarray(cropped), stats
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 288 |
+
# GRADIO UI
|
| 289 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 290 |
+
|
| 291 |
+
CSS = """
|
| 292 |
+
.gradio-container { max-width: 1100px !important; }
|
| 293 |
+
#title { text-align: center; font-family: 'Courier New', monospace; }
|
| 294 |
+
"""
|
| 295 |
+
|
| 296 |
+
with gr.Blocks(title="Bolt Hole Localizer", theme=gr.themes.Monochrome(), css=CSS) as demo:
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
gr.Markdown(
|
| 299 |
"""
|
| 300 |
+
# π© Engine Bolt Hole Localizer β Production CV Pipeline
|
| 301 |
+
**Upload an engine block image.** The pipeline will:
|
| 302 |
+
1. **CLAHE** contrast normalisation + Gaussian smoothing
|
| 303 |
+
2. **Multi-scale HoughCircles** sweep across 4 parameter sets
|
| 304 |
+
3. **NMS deduplication** to eliminate duplicate detections
|
| 305 |
+
4. **K-means row clustering** β robust top / bottom split
|
| 306 |
+
5. **IQR outlier rejection** within each row
|
| 307 |
+
6. **Least-squares horizontal lines** through each row centroid
|
| 308 |
+
7. **Tight bounding box** + **cropped ROI** output
|
| 309 |
+
""",
|
| 310 |
+
elem_id="title"
|
| 311 |
)
|
| 312 |
|
| 313 |
with gr.Row():
|
|
|
|
| 315 |
|
| 316 |
with gr.Row():
|
| 317 |
extend_slider = gr.Slider(
|
| 318 |
+
minimum=10, maximum=120, value=40, step=5,
|
| 319 |
+
label="Bounding Box Padding (px)",
|
| 320 |
+
info="Extra pixels added on each side of the outermost holes"
|
|
|
|
|
|
|
| 321 |
)
|
| 322 |
|
| 323 |
+
run_btn = gr.Button("π Detect Bolt Holes", variant="primary", size="lg")
|
| 324 |
|
| 325 |
with gr.Row():
|
| 326 |
+
out_annotated = gr.Image(label="π Annotated β Holes + Horizontal Lines + Bounding Box")
|
| 327 |
+
out_cropped = gr.Image(label="βοΈ Cropped ROI")
|
| 328 |
|
| 329 |
+
out_stats = gr.Markdown(label="Detection Report")
|
| 330 |
|
| 331 |
run_btn.click(
|
| 332 |
fn=detect_bolt_holes,
|
|
|
|
| 334 |
outputs=[out_annotated, out_cropped, out_stats],
|
| 335 |
)
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
if __name__ == "__main__":
|
| 338 |
demo.launch()
|