eho69 commited on
Commit
bc98998
Β·
verified Β·
1 Parent(s): a3fd7df

line cropping

Browse files
Files changed (1) hide show
  1. app.py +294 -121
app.py CHANGED
@@ -1,133 +1,313 @@
1
- import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import cv2
3
  import numpy as np
4
-
5
  from PIL import Image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- def detect_bolt_holes(image: np.ndarray, extend_px: int = 40):
8
  """
9
- Steps:
10
- 1. Detect all circular bolt holes via HoughCircles
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
- img_rgb = np.array(image)
17
- gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
18
-
19
- # ── Step 1: Hough Circle Detection ──────────────────────────────────────
20
- circles = cv2.HoughCircles(
21
- gray,
22
- cv2.HOUGH_GRADIENT,
23
- dp=1,
24
- minDist=30,
25
- param1=80,
26
- param2=30,
27
- minRadius=10,
28
- maxRadius=35,
29
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- if circles is None:
32
- return image, image, "❌ No circles detected. Try a clearer engine image."
 
 
 
 
 
 
 
 
 
 
33
 
34
- circles = np.round(circles[0]).astype(int)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Keep only 4 best per row (sorted by x, filtered by y-spread)
43
- def pick_row(pts, n=4):
44
- pts = sorted(pts, key=lambda p: p[0])
45
- return pts[:n] if len(pts) >= n else pts
46
 
47
- top_row = pick_row(top_candidates)
48
- bot_row = pick_row(bot_candidates)
49
- all_holes = top_row + bot_row
 
 
 
 
 
50
 
51
- if len(all_holes) < 4:
52
- return image, image, f"⚠️ Only {len(all_holes)} holes found β€” need at least 4."
53
 
54
- # ── Step 3: Draw on canvas ───────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  vis = img_rgb.copy()
56
- HOLE_COLOR = (255, 220, 0) # yellow rings
57
- DOT_COLOR = (255, 60, 60) # red center dots
58
- HLINE_COLOR = (0, 230, 80) # green horizontal connectors
59
- BBOX_COLOR = (255, 80, 80) # red bounding box
60
- EXT_COLOR = (0, 180, 255) # cyan extended lines
61
-
62
- # Mark each bolt hole
63
- hole_r = 20
64
- for x, y in all_holes:
65
- cv2.circle(vis, (x, y), hole_r, HOLE_COLOR, 3)
66
- cv2.circle(vis, (x, y), 5, DOT_COLOR, -1)
67
-
68
- # Horizontal green lines between holes in each row
69
  if len(top_row) >= 2:
70
- cv2.line(vis, top_row[0], top_row[-1], HLINE_COLOR, 3)
 
 
71
  if len(bot_row) >= 2:
72
- cv2.line(vis, bot_row[0], bot_row[-1], HLINE_COLOR, 3)
73
-
74
- # ── Step 4: Extended bounding box ───────────────────────────────────────
75
- all_x = [p[0] for p in all_holes]
76
- all_y = [p[1] for p in all_holes]
77
- x_min = min(all_x) - extend_px
78
- x_max = max(all_x) + extend_px
79
- y_min = min(all_y) - extend_px
80
- y_max = max(all_y) + extend_px
81
-
82
- # Cyan extended horizontal lines (full width of bbox)
83
- if top_row:
84
- y_top = int(np.mean([p[1] for p in top_row]))
85
- cv2.line(vis, (x_min, y_top), (x_max, y_top), EXT_COLOR, 2)
86
- if bot_row:
87
- y_bot = int(np.mean([p[1] for p in bot_row]))
88
- cv2.line(vis, (x_min, y_bot), (x_max, y_bot), EXT_COLOR, 2)
89
-
90
- # Red bounding box (vertical connectors close the rectangle)
91
- cv2.rectangle(vis, (x_min, y_min), (x_max, y_max), BBOX_COLOR, 3)
92
-
93
- # Redraw holes on top so box doesn't cover them
94
- for x, y in all_holes:
95
- cv2.circle(vis, (x, y), hole_r, HOLE_COLOR, 3)
96
- cv2.circle(vis, (x, y), 5, DOT_COLOR, -1)
97
-
98
- # ── Step 5: Crop ROI ─────────────────────────────────────────────────────
99
- h, w = img_rgb.shape[:2]
100
- cx1 = max(0, x_min)
101
- cy1 = max(0, y_min)
102
- cx2 = min(w, x_max)
103
- cy2 = min(h, y_max)
104
- cropped = img_rgb[cy1:cy2, cx1:cx2]
 
 
 
 
 
 
 
 
 
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  stats = (
107
- f"βœ… Detected **{len(all_holes)} bolt holes**\n"
108
- f"β€’ Top row ({len(top_row)} holes): {top_row}\n"
109
- f"β€’ Bottom row ({len(bot_row)} holes): {bot_row}\n"
110
- f"β€’ Bounding Box: ({x_min}, {y_min}) β†’ ({x_max}, {y_max})\n"
111
- f"β€’ Cropped ROI size: {cx2-cx1} Γ— {cy2-cy1} px"
 
112
  )
113
 
114
- return Image.fromarray(vis), Image.fromarray(cropped), stats
 
 
 
 
 
 
 
 
 
 
 
 
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 Localization
124
- ### Senior Computer Vision Pipeline
125
- Upload an engine block image. The model will:
126
- 1. **Detect** all 8 bolt holes (4 top + 4 bottom)
127
- 2. **Connect** each row with horizontal lines
128
- 3. **Extend** lines and add vertical connectors β†’ tight bounding box
129
- 4. **Crop** and return the detected region
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=100, value=40, step=5,
139
- label="Bounding Box Extension (px)"
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 – Holes + Bounding Box")
149
- out_cropped = gr.Image(label="βœ‚οΈ Cropped ROI")
150
 
151
- out_stats = gr.Markdown(label="Detection Stats")
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()