eho69 commited on
Commit
7aadeb6
Β·
verified Β·
1 Parent(s): 3a3244f

add the pca compress

Browse files
Files changed (1) hide show
  1. app.py +545 -517
app.py CHANGED
@@ -1,3 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
  import cv2
3
  import numpy as np
@@ -7,6 +20,8 @@ import logging
7
  import torch
8
  from torchvision import models, transforms
9
  from PIL import Image
 
 
10
 
11
  logging.basicConfig(level=logging.INFO)
12
  logger = logging.getLogger(__name__)
@@ -15,85 +30,70 @@ logger = logging.getLogger(__name__)
15
  # CONSTANTS
16
  # ───────────────────────────────────────────────────────────────────────────────
17
 
18
- TEMPLATE_FILE = "templates.pkl"
19
- CLUSTER_VERSION = "v4"
20
- TEXTURE_WEIGHT = 1.6
21
- MIN_SAMPLES_WARN = 5
22
- MIN_MATCH_SAMPLES = 3
 
 
 
23
 
24
 
25
  # ───────────────────────────────────────────────────────────────────────────────
26
- # ENHANCED CLAHE PIPELINE
27
  # ───────────────────────────────────────────────────────────────────────────────
28
 
29
  class CLAHEProcessor:
30
-
31
- # Tunable parameters ── adjust per camera / lighting environment
32
- CLAHE_CLIP_LIMIT = 3.0 # higher β†’ more contrast boost but more noise
33
- CLAHE_TILE_SIZE = (8, 8) # smaller tiles β†’ more local; larger β†’ more global
34
- BILATERAL_D = 9 # diameter of bilateral filter kernel
35
- BILATERAL_SIGMA_C = 75 # colour sigma (higher β†’ more smoothing)
36
- BILATERAL_SIGMA_S = 75 # spatial sigma (higher β†’ larger smoothing area)
37
- UNSHARP_STRENGTH = 0.6 # 0 = no sharpening, 1 = full unsharp mask
38
 
39
  @classmethod
40
  def process(cls, rgb: np.ndarray) -> np.ndarray:
41
- """Full pipeline: rgb uint8 β†’ enhanced rgb uint8."""
42
- # ── Stage 1: Homomorphic illumination normalisation ───────────────────
43
- lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
44
- l, a, b = cv2.split(lab)
45
- l_f = np.float64(l) + 1.0
46
- l_log = np.log(l_f)
47
- illum = cv2.GaussianBlur(l_log, (31, 31), 0)
48
- reflect = cv2.normalize(l_log - illum, None, 0, 255, cv2.NORM_MINMAX)
49
- l_homo = np.uint8(reflect)
50
-
51
- # ── Stage 2: Adaptive CLAHE on normalised L channel ───────────────────
52
- clahe = cv2.createCLAHE(
53
- clipLimit = cls.CLAHE_CLIP_LIMIT,
54
- tileGridSize= cls.CLAHE_TILE_SIZE,
55
- )
56
- l_clahe = clahe.apply(l_homo)
57
-
58
- # ── Stage 3: Bilateral denoising (edge-preserving) ────────────────────
59
- # Merge back to BGR for bilateral (works per-channel in LAB space too)
60
- lab_clahe = cv2.merge((l_clahe, a, b))
61
- rgb_clahe = cv2.cvtColor(lab_clahe, cv2.COLOR_LAB2RGB)
62
- bgr_clahe = cv2.cvtColor(rgb_clahe, cv2.COLOR_RGB2BGR)
63
- bgr_den = cv2.bilateralFilter(
64
- bgr_clahe,
65
- cls.BILATERAL_D,
66
- cls.BILATERAL_SIGMA_C,
67
- cls.BILATERAL_SIGMA_S,
68
- )
69
- rgb_den = cv2.cvtColor(bgr_den, cv2.COLOR_BGR2RGB)
70
-
71
- # ── Stage 4: Unsharp mask (sharpens defect edges / surface texture) ───
72
- blur = cv2.GaussianBlur(rgb_den, (5, 5), 0)
73
- sharpened = cv2.addWeighted(
74
- rgb_den, 1.0 + cls.UNSHARP_STRENGTH,
75
- blur, -cls.UNSHARP_STRENGTH,
76
- 0,
77
- )
78
-
79
- return np.clip(sharpened, 0, 255).astype(np.uint8)
80
 
81
  @classmethod
82
  def preview(cls, rgb: np.ndarray) -> np.ndarray:
83
- """Return a side-by-side comparison: original | enhanced."""
84
- enhanced = cls.process(rgb)
85
- h = max(rgb.shape[0], enhanced.shape[0])
86
- orig_r = cv2.resize(rgb, (rgb.shape[1], h))
87
- enh_r = cv2.resize(enhanced, (enhanced.shape[1], h))
88
-
89
- # Add labels
90
- def _label(img, txt):
91
  out = img.copy()
92
- cv2.putText(out, txt, (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
93
- 0.9, (255, 255, 0), 2, cv2.LINE_AA)
94
  return out
95
-
96
- return np.hstack([_label(orig_r, "Original"), _label(enh_r, "Enhanced")])
97
 
98
 
99
  # ───────────────────────────────────────────────────────────────────────────────
@@ -101,106 +101,147 @@ class CLAHEProcessor:
101
  # ───────────────────────────────────────────────────────────────────────────────
102
 
103
  class FeatureExtractor:
104
-
105
  def __init__(self):
106
- self.backbone = models.resnet50(weights="IMAGENET1K_V1")
107
  self.backbone.eval()
108
  self.transform = transforms.Compose([
109
- transforms.Resize((224, 224)),
110
  transforms.ToTensor(),
111
  transforms.Normalize(mean=[0.485,0.456,0.406],
112
  std =[0.229,0.224,0.225]),
113
  ])
114
 
115
  @staticmethod
116
- def extract_texture_features(gray: np.ndarray) -> np.ndarray:
117
- features = []
118
- g = gray.astype(np.float64)
119
-
120
- gx = cv2.Sobel(g, cv2.CV_64F, 1, 0, ksize=3)
121
- gy = cv2.Sobel(g, cv2.CV_64F, 0, 1, ksize=3)
122
- mag = np.sqrt(gx**2 + gy**2)
123
- ang = np.arctan2(gy, gx)
124
-
125
- mh, _ = np.histogram(mag, bins=32, density=True)
126
- features.extend(mh.tolist())
127
- ah, _ = np.histogram(ang, bins=36, range=(-np.pi, np.pi), density=True)
128
- features.extend(ah.tolist())
129
-
130
- h, w = gray.shape
131
- ph, pw = max(1, h//4), max(1, w//4)
132
  for i in range(4):
133
  for j in range(4):
134
  p = gray[i*ph:(i+1)*ph, j*pw:(j+1)*pw]
135
  if p.size == 0:
136
- features.extend([0.0]*4); continue
137
  pf = p.astype(np.float64)
138
- features.append(float(np.std(pf)))
139
- hp, _ = np.histogram(p, bins=32, range=(0,256), density=True)
140
- hp = hp[hp > 0]
141
- features.append(-float(np.sum(hp * np.log2(hp + 1e-10))))
142
- features.append(float(np.mean(cv2.Canny(p, 50, 150)) / 255.0))
143
- gxp = cv2.Sobel(pf, cv2.CV_64F, 1, 0, ksize=3)
144
- gyp = cv2.Sobel(pf, cv2.CV_64F, 0, 1, ksize=3)
145
- features.append(float(np.mean(np.sqrt(gxp**2 + gyp**2))))
146
 
147
  for theta in [0, np.pi/4, np.pi/2, 3*np.pi/4]:
148
- for sigma in [3.0, 5.0]:
149
- k = cv2.getGaborKernel((21,21), sigma, theta, 10.0, 0.5, 0, ktype=cv2.CV_64F)
150
  f = cv2.filter2D(g, cv2.CV_64F, k)
151
- features.extend([float(np.mean(f)), float(np.std(f))])
152
 
153
- return np.array(features, dtype=np.float64)
154
 
155
- def extract(self, rgb) -> tuple:
 
156
  if isinstance(rgb, Image.Image):
157
  rgb = np.array(rgb.convert("RGB"))
158
- if rgb.dtype != np.uint8:
159
- rgb = rgb.astype(np.uint8)
160
  if len(rgb.shape) == 2:
161
  rgb = cv2.cvtColor(rgb, cv2.COLOR_GRAY2RGB)
162
 
163
- # ── CLAHE Enhancement (new) ───────────────────────────────────────────
164
  rgb_enh = CLAHEProcessor.process(rgb)
165
 
166
- # ── Mid-level CNN features ────────────────────────────────────────────
167
  t = self.transform(Image.fromarray(rgb_enh)).unsqueeze(0)
168
  with torch.no_grad():
169
- x = self.backbone.maxpool(
170
- self.backbone.relu(
171
- self.backbone.bn1(
172
- self.backbone.conv1(t))))
173
  x = self.backbone.layer1(x)
174
  fl2 = self.backbone.layer2(x)
175
  fl3 = self.backbone.layer3(fl2)
 
 
176
 
177
- cnn_l2 = torch.mean(fl2, dim=[2,3]).squeeze().cpu().numpy()
178
- cnn_l3 = torch.mean(fl3, dim=[2,3]).squeeze().cpu().numpy()
 
 
 
 
179
 
180
- amap = torch.sum(fl3, dim=1).squeeze().cpu().numpy()
181
- amap = np.maximum(amap, 0)
182
- amap /= (np.max(amap) + 1e-8)
183
- amap = cv2.resize(amap, (rgb.shape[1], rgb.shape[0]))
184
- heatmap= cv2.applyColorMap(np.uint8(255*amap), cv2.COLORMAP_JET)
185
- overlay= cv2.addWeighted(
186
- rgb, 0.6,
187
- cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB), 0.4, 0)
188
 
189
- # ── Texture features ──────────────────────────────────────────────────
190
- gray_enh = cv2.cvtColor(rgb_enh, cv2.COLOR_RGB2GRAY)
191
- texture_feat= self.extract_texture_features(gray_enh)
 
 
 
192
 
193
- # ── Combine + normalise ───────────────────────────────────────────────
194
- cnn = np.concatenate([cnn_l2, cnn_l3])
195
- cn = np.linalg.norm(cnn)
196
- cu = cnn / cn if cn > 1e-8 else cnn
197
 
198
- tn = np.linalg.norm(texture_feat)
199
- tu = texture_feat / tn if tn > 1e-8 else texture_feat
 
200
 
201
- combined = np.concatenate([cu, tu * TEXTURE_WEIGHT])
202
- norm = np.linalg.norm(combined)
203
- return combined / norm if norm > 1e-8 else combined, overlay
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
 
206
  # ───────────────────────────────────────────────────────────────────────────────
@@ -210,29 +251,54 @@ class FeatureExtractor:
210
  class EnginePartDetector:
211
 
212
  def __init__(self):
213
- self.feature_extractor = FeatureExtractor()
214
- self.classes: dict[str, list[np.ndarray]] = {}
 
 
 
 
215
  self.centroids: dict[str, np.ndarray] = {}
216
  self.class_spread: dict[str, float] = {}
 
217
  self.class_rois: dict[str, np.ndarray] = {}
218
  self._load_data()
219
 
220
- # ── Centroid helpers ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  def _compute_centroid(self, name: str) -> None:
223
- vecs = np.array(self.classes[name])
 
 
 
 
 
224
  centroid = np.mean(vecs, axis=0)
 
 
 
225
  if len(vecs) > 1:
226
- dists = [self._cosine(v, centroid) for v in vecs]
227
  self.class_spread[name] = float(np.std(dists)) + 1e-6
228
  else:
229
  self.class_spread[name] = 1.0
230
- n = np.linalg.norm(centroid)
231
- self.centroids[name] = centroid / n if n > 1e-8 else centroid
232
 
233
- def _rebuild_all_centroids(self) -> None:
234
- for name in self.classes:
235
- self._compute_centroid(name)
 
 
 
236
 
237
  # ── Persistence ───────────────────────────────────────────────────────────
238
 
@@ -242,8 +308,10 @@ class EnginePartDetector:
242
  pickle.dump({
243
  "version": CLUSTER_VERSION,
244
  "texture_weight": TEXTURE_WEIGHT,
 
245
  "classes": self.classes,
246
  "rois": self.class_rois,
 
247
  }, f)
248
  except Exception as e:
249
  logger.error(f"Save failed: {e}")
@@ -252,15 +320,17 @@ class EnginePartDetector:
252
  if not os.path.exists(TEMPLATE_FILE):
253
  return
254
  try:
255
- with open(TEMPLATE_FILE, "rb") as f:
256
  data = pickle.load(f)
257
  if (data.get("version") != CLUSTER_VERSION or
258
- data.get("texture_weight") != TEXTURE_WEIGHT):
 
259
  logger.warning("Stale cluster file β€” discarding.")
260
- os.remove(TEMPLATE_FILE)
261
- return
262
- self.classes = data.get("classes", {})
263
- self.class_rois = data.get("rois", {})
 
264
  self._rebuild_all_centroids()
265
  logger.info(f"Loaded {len(self.classes)} class(es).")
266
  except Exception as e:
@@ -270,182 +340,169 @@ class EnginePartDetector:
270
  # ── Layer 1 β€” ROI localisation ────────────────────────────────────────────
271
 
272
  @staticmethod
273
- def detect_connect_and_crop(image_source: np.ndarray) -> tuple:
274
- img_rgb = image_source
275
  img_h, img_w = img_rgb.shape[:2]
276
- gray = cv2.GaussianBlur(
277
- cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY), (7,7), 0)
278
-
279
- scale = img_w / 1000.0
280
- circles = cv2.HoughCircles(
281
  gray, cv2.HOUGH_GRADIENT, dp=1.2,
282
- minDist =max(30, int(60*scale)),
283
- param1=100, param2=35,
284
- minRadius=max(5, int(12*scale)),
285
- maxRadius=max(20, int(45*scale)),
286
- )
287
  if circles is None:
288
  return img_rgb, img_rgb, "❌ No bolt holes detected."
289
 
290
  circles = np.round(circles[0]).astype(int)
291
- ys = sorted([c[1] for c in circles])
292
  y_med = np.median(ys)
293
- top_row = sorted([c for c in circles if c[1] < y_med], key=lambda x: x[0])
294
- bot_row = sorted([c for c in circles if c[1] >= y_med], key=lambda x: x[0])
295
 
296
- if len(top_row) < 2 or len(bot_row) < 2:
297
  return img_rgb, img_rgb, "⚠️ Insufficient hole rows."
298
 
299
- y_top = int(np.mean([c[1] for c in top_row]))
300
- y_bot = int(np.mean([c[1] for c in bot_row]))
301
- xs = [c[0] for c in circles]
302
- x_start = max(0, min(xs) - 60)
303
- x_end = min(img_w, max(xs) + 60)
304
- y_start = max(0, min(y_top, y_bot) - 20)
305
- y_end = min(img_h, max(y_top, y_bot) + 20)
306
 
307
  vis = img_rgb.copy()
308
- cv2.line(vis, (0, y_top), (img_w, y_top), (0,255,0), 3)
309
- cv2.line(vis, (0, y_bot), (img_w, y_bot), (0,255,0), 3)
310
- for (x, y, r) in circles:
311
- cv2.circle(vis, (x,y), r, (255,0,0), 3)
312
- cv2.circle(vis, (x,y), 2, (255,255,255), -1)
313
 
314
- crop = img_rgb[y_start:y_end, x_start:x_end]
315
  if crop.size == 0:
316
  return vis, img_rgb, "⚠️ ROI crop failed."
317
 
318
- stats = (f"βœ… ROI extracted | {len(circles)} holes | "
319
  f"{len(top_row)} top, {len(bot_row)} bottom | "
320
- f"size {crop.shape[1]}Γ—{crop.shape[0]} px")
321
  return vis, crop, stats
322
 
323
  # ── Internal helpers ──────────────────────────────────────────────────────
324
 
325
  @staticmethod
326
- def _cosine(a: np.ndarray, b: np.ndarray) -> float:
327
- na, nb = np.linalg.norm(a), np.linalg.norm(b)
328
- if na < 1e-8 or nb < 1e-8:
329
- return 0.0
330
- return float(np.dot(a, b) / (na * nb))
331
-
332
- def _add_feature(self, features: np.ndarray, class_name: str,
333
- roi: np.ndarray | None = None) -> None:
334
- """Internal: append features, refresh centroid, optionally store ROI."""
335
- if class_name not in self.classes:
336
- self.classes[class_name] = []
337
- self.classes[class_name].append(features)
338
- self._compute_centroid(class_name)
339
- if roi is not None:
340
- self.class_rois[class_name] = CLAHEProcessor.process(roi)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
  # ── Public API β€” single image ─────────────────────────────────────────────
343
 
344
  def add_to_class(self, image: np.ndarray, class_name: str) -> tuple:
345
- if image is None:
346
- return "❌ No image supplied.", None
347
- if not class_name or not class_name.strip():
348
- return "❌ Class name is empty.", None
349
 
350
  class_name = class_name.strip()
351
- vis, roi, log = self.detect_connect_and_crop(image)
352
  if "❌" in log or "⚠️" in log:
353
  return log, None
354
 
355
- features, _ = self.feature_extractor.extract(roi)
356
- self._add_feature(features, class_name, roi)
 
 
 
 
 
357
  self._persist_data()
358
 
359
  n = len(self.classes[class_name])
360
- warn = (f"\n⚠️ Only {n} sample(s). Add β‰₯{MIN_SAMPLES_WARN} for reliable results."
361
- if n < MIN_SAMPLES_WARN else "")
362
- return f"βœ… Added to '{class_name}' ({n} sample(s)){warn}\n{log}", roi
363
-
364
- # ── Public API β€” BULK upload ──────────────────────────────────────────────
365
-
366
- def add_bulk_to_class(
367
- self,
368
- file_paths: list[str],
369
- class_name: str,
370
- progress_cb=None,
371
- ) -> tuple[str, list[str], np.ndarray | None]:
372
- """
373
- Process a list of image file-paths and add each to class_name.
374
-
375
- Parameters
376
- ----------
377
- file_paths : list of file paths from gr.File component
378
- class_name : target class label
379
- progress_cb : optional callable(done, total) for Gradio progress
380
-
381
- Returns
382
- -------
383
- summary : markdown report string
384
- log_lines : per-image status list (for display in Textbox)
385
- last_roi : last successfully extracted ROI (for preview)
386
- """
387
- if not file_paths:
388
- return "❌ No files selected.", [], None
389
- if not class_name or not class_name.strip():
390
- return "❌ Class name is empty.", [], None
391
 
392
  class_name = class_name.strip()
393
- total = len(file_paths)
394
- ok_count = 0
395
- fail_count = 0
396
- log_lines = []
397
- last_roi = None
398
 
399
  for idx, fp in enumerate(file_paths):
400
- # fp is either a string path or a dict with 'name' key (Gradio)
401
- path = fp if isinstance(fp, str) else fp.get("name", str(fp))
402
  fname = os.path.basename(path)
403
-
404
  try:
405
- img_pil = Image.open(path).convert("RGB")
406
- image = np.array(img_pil)
407
  except Exception as e:
408
  log_lines.append(f"❌ [{idx+1}/{total}] {fname} β€” load error: {e}")
409
- fail_count += 1
410
- continue
411
 
412
- vis, roi, loc_log = self.detect_connect_and_crop(image)
413
-
414
- if "❌" in loc_log or "⚠️" in loc_log:
415
- log_lines.append(f"⚠️ [{idx+1}/{total}] {fname} β€” {loc_log}")
416
- fail_count += 1
417
- continue
418
 
419
  try:
420
- features, _ = self.feature_extractor.extract(roi)
421
- self._add_feature(features, class_name, roi)
422
- last_roi = roi
423
- ok_count += 1
424
- log_lines.append(f"βœ… [{idx+1}/{total}] {fname} β€” added")
 
425
  except Exception as e:
426
- log_lines.append(f"❌ [{idx+1}/{total}] {fname} β€” feature error: {e}")
427
- fail_count += 1
428
 
429
- if progress_cb:
430
- progress_cb(idx + 1, total)
431
 
432
- # Save once after all images processed (much faster than per-image save)
433
- if ok_count > 0:
 
434
  self._persist_data()
435
 
436
- n_class = len(self.classes.get(class_name, []))
437
- warn = (f"\n⚠️ '{class_name}' has {n_class} sample(s). "
438
- f"Add β‰₯{MIN_SAMPLES_WARN} per class for reliable results."
439
- if n_class < MIN_SAMPLES_WARN else "")
440
-
441
  summary = (
442
- f"### Bulk Upload Complete\n"
443
- f"- **Class**: `{class_name}`\n"
444
- f"- **Total files**: {total}\n"
445
- f"- βœ… **Added**: {ok_count}\n"
446
- f"- ❌ **Failed / skipped**: {fail_count}\n"
447
- f"- **Total '{class_name}' samples**: {n_class}"
448
- f"{warn}"
449
  )
450
  return summary, log_lines, last_roi
451
 
@@ -453,145 +510,178 @@ class EnginePartDetector:
453
 
454
  def match_part(self, image: np.ndarray, threshold: float = 0.75) -> tuple:
455
  if image is None:
456
- return "❌ No image supplied.", None, None, None, None
457
  if not self.classes:
458
- return ("⚠️ No trained classes yet. "
459
- "Add samples first.", None, None, None, None)
460
 
461
- vis, roi, log = self.detect_connect_and_crop(image)
462
  if "❌" in log or "⚠️" in log:
463
- return f"❌ Localisation failed: {log}", None, vis, None, None
464
 
465
- query_feat, attention_map = self.feature_extractor.extract(roi)
466
 
467
- # Only use classes that meet minimum sample threshold
468
- eligible = {n: c for n, c in self.centroids.items()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  if len(self.classes[n]) >= MIN_MATCH_SAMPLES}
470
  skipped = [n for n in self.classes if n not in eligible]
471
 
472
  if not eligible:
473
- return (f"⚠️ No class has β‰₯{MIN_MATCH_SAMPLES} samples yet.",
474
- None, vis, None, None)
475
 
476
- # ── Centroid cosine scoring with spread penalty ───────────────────────
477
  class_scores = []
478
  for name, centroid in eligible.items():
479
- raw_sim = self._cosine(query_feat, centroid)
480
- spread = self.class_spread.get(name, 1.0)
481
- adj_sim = raw_sim / (1.0 + spread) # penalise noisy clusters
482
- class_scores.append((name, adj_sim, raw_sim))
483
-
484
- class_scores.sort(key=lambda x: x[1], reverse=True)
485
- best_name, best_adj, best_raw = class_scores[0]
486
- second_adj = class_scores[1][1] if len(class_scores) > 1 else 0.0
487
- raw_gap = best_adj - second_adj
488
-
489
- # ── Sharp softmax (T=0.05) ────────────────────────────────────────────
490
- TEMPERATURE = 0.05
491
- adj_arr = np.array([s[1] for s in class_scores])
492
- exp_scores = np.exp((adj_arr - np.max(adj_arr)) / TEMPERATURE)
493
- probs = exp_scores / np.sum(exp_scores)
494
 
495
  # ── Balance weight (imbalance correction) ─────────────────────────────
496
- total_samples = sum(len(self.classes[n]) for n in eligible)
497
- n_classes = len(eligible)
498
- raw_weighted = []
499
- for (name, adj, raw), p in zip(class_scores, probs):
500
- w = (total_samples / (n_classes * len(self.classes[name])))
501
- raw_weighted.append((name, p * w, raw))
502
-
503
- total_w = sum(x[1] for x in raw_weighted)
504
- class_probs= [(n, p/total_w, r) for n, p, r in raw_weighted]
505
- class_probs.sort(key=lambda x: x[1], reverse=True)
506
-
507
- best_class = class_probs[0][0]
508
- best_prob = class_probs[0][1]
509
-
510
- MIN_GAP = 0.003
511
- matched = best_prob >= threshold and raw_gap >= MIN_GAP
512
-
513
- if matched:
514
- status = ("βœ… PASS" if "perfect" in best_class.lower()
515
- else f"❌ FAIL β€” {best_class}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  else:
517
- status = "❓ UNCERTAIN"
 
 
 
 
 
 
 
 
 
 
 
 
518
 
519
  lines = [
520
- f"**🏷️ Prediction** : `{best_class}`",
521
- f"**πŸ“Š Confidence** : {best_prob:.2%}",
522
- f"**πŸ“ Best Cosine** : {class_probs[0][2]:.4f}",
523
- f"**↔️ Margin** : {raw_gap:.4f}",
524
- f"**🎯 Status** : {status}",
525
  "",
 
526
  ]
527
- if skipped:
528
- lines.append(f"⚠️ Skipped (too few samples): {', '.join(skipped)}")
529
- lines.append("")
530
 
531
  lines += [
 
 
 
532
  "### Pipeline",
533
- "1. ROI localisation β†’ bolt holes detected",
534
- "2. CLAHE enhancement β†’ high-contrast normalisation",
535
- "3. ResNet-50 layer2+layer3 + texture features",
536
- "4. Centroid cosine + spread penalty + balance weight",
537
- "---", log, "",
538
- "**Class probabilities:**",
539
  ]
540
- for name, prob, raw in class_probs:
541
- marker = "πŸ‘‰ " if name == best_class else " "
542
- lines.append(f"{marker}`{name}`: {prob:.1%} (cosine: {raw:.4f})")
543
 
544
- label_dict = {n: float(p) for n, p, _ in class_probs}
545
 
546
  roi_e = CLAHEProcessor.process(roi)
547
  gray_e = cv2.cvtColor(roi_e, cv2.COLOR_RGB2GRAY)
548
- edges = cv2.cvtColor(cv2.Canny(gray_e, 50, 150), cv2.COLOR_GRAY2RGB)
549
 
550
- return "\n".join(lines), label_dict, vis, attention_map, edges
551
 
552
  # ── Utility ───────────────────────────────────────────────────────────────
553
 
554
- def get_template_roi(self, class_name: str):
555
- return self.class_rois.get(class_name)
556
 
557
  def list_templates(self) -> str:
558
- if not self.classes:
559
- return "No classes trained yet."
560
- lines = [f"Total: {len(self.classes)} class(es) | "
561
- f"Version: {CLUSTER_VERSION} | "
562
- f"Texture weight: {TEXTURE_WEIGHT}", "─"*40]
563
  total = sum(len(v) for v in self.classes.values())
 
 
 
 
 
564
  for name, vecs in sorted(self.classes.items()):
565
  pct = 100*len(vecs)/total if total else 0
566
- warn = f" ⚠️ need {MIN_SAMPLES_WARN-len(vecs)} more" if len(vecs) < MIN_SAMPLES_WARN else ""
567
- lines.append(f" β€’ {name}: {len(vecs)} samples ({pct:.0f}%){warn}")
568
- lines.append(f"\n Total samples: {total}")
569
  return "\n".join(lines)
570
 
571
- def delete_class(self, class_name: str) -> bool:
572
- if class_name in self.classes:
573
- del self.classes[class_name]
574
- for d in [self.centroids, self.class_spread, self.class_rois]:
575
- d.pop(class_name, None)
 
576
  self._persist_data()
577
  return True
578
  return False
579
 
580
  def reset_all(self) -> str:
581
- self.classes = {}; self.centroids = {}
582
- self.class_spread = {}; self.class_rois = {}
583
- if os.path.exists(TEMPLATE_FILE):
584
- os.remove(TEMPLATE_FILE)
585
- return "βœ… All classes cleared."
586
 
587
 
588
  # ───────────────────────────────────────────────────────────────────────────────
589
- # GRADIO APPLICATION
590
  # ───────────────────────────────────────────────────────────────────────────────
591
 
592
  detector = EnginePartDetector()
593
 
594
-
595
  def detect_part(image, threshold):
596
  return detector.match_part(image, threshold)
597
 
@@ -599,203 +689,141 @@ def add_sample(image, class_name):
599
  return detector.add_to_class(image, class_name)
600
 
601
  def add_bulk(files, class_name, progress=gr.Progress()):
602
- paths = [f.name if hasattr(f, "name") else f for f in (files or [])]
603
-
604
- def cb(done, total):
605
- progress(done / total, desc=f"Processing {done}/{total}...")
606
-
607
  summary, log_lines, last_roi = detector.add_bulk_to_class(paths, class_name, cb)
608
- log_text = "\n".join(log_lines)
609
- return summary, log_text, last_roi
610
 
611
  def clahe_preview(image):
612
- if image is None:
613
- return None
614
- return CLAHEProcessor.preview(image)
615
 
616
  def update_library_preview():
617
  txt = detector.list_templates()
618
- if detector.classes:
619
- first = sorted(detector.classes.keys())[0]
620
- return txt, detector.get_template_roi(first)
621
- return txt, None
622
 
623
  def delete_class_ui(class_name):
624
  ok = detector.delete_class(class_name)
625
- msg = f"βœ… Deleted '{class_name}'." if ok else f"❌ '{class_name}' not found."
626
  txt, roi = update_library_preview()
627
  return msg, txt, roi
628
 
629
  def reset_all_ui():
630
- msg = detector.reset_all()
631
- return msg, "No classes trained yet.", None
632
 
633
 
634
  custom_css = """
635
- .header { text-align:center; margin-bottom:1.5rem; }
636
- .footer { text-align:center; margin-top:1.5rem; color:#666; }
637
  """
638
 
639
- with gr.Blocks(title="Engine Part CV System", theme=gr.themes.Soft(),
640
- css=custom_css) as demo:
641
 
642
  gr.Markdown("""
643
  <div class="header">
644
- <h1>πŸ”§ Engine Part CV System</h1>
645
- <p>
646
- <strong>Pipeline:</strong>
647
- ROI Localisation β†’ CLAHE Enhancement
648
- β†’ ResNet-50 Features + Texture β†’ Centroid Cosine Matching
649
- </p>
650
- <p>⚠️ <em>Train each class with β‰₯10 images before running inspections.</em></p>
651
  </div>
652
  """)
653
 
654
- # ── Tab 1 β€” Inspect ───────────────────────────────────────────────────────
655
  with gr.Tab("πŸ” Inspect Part"):
656
  with gr.Row():
657
- with gr.Column(scale=1):
658
- detect_input = gr.Image(sources=["upload","webcam"],
659
- type="numpy", label="Input Image")
660
- threshold_slider = gr.Slider(0.50, 0.99, value=0.75, step=0.01,
661
- label="Confidence Threshold")
662
- detect_btn = gr.Button("πŸ” Run Inspection", variant="primary")
663
-
664
- with gr.Column(scale=1):
665
- detect_output = gr.Markdown(label="Report")
666
- match_label = gr.Label(label="Class Probabilities", num_top_classes=5)
667
  with gr.Row():
668
- vis_output = gr.Image(label="Field Visualisation")
669
- attn_output = gr.Image(label="AI Attention Heatmap")
670
- edge_output = gr.Image(label="Edge Map")
671
-
672
- detect_btn.click(
673
- fn=detect_part,
674
- inputs=[detect_input, threshold_slider],
675
- outputs=[detect_output, match_label, vis_output, attn_output, edge_output],
676
- api_name="detect_part",
677
- )
678
 
679
- # ── Tab 2 β€” Single Train ──────────────────────────────────────────────────
680
- with gr.Tab("πŸ’Ύ Train β€” Single Image"):
681
- with gr.Row():
682
- with gr.Column(scale=1):
683
- template_input = gr.Image(sources=["upload"], type="numpy",
684
- label="Training Image")
685
- class_name_input = gr.Dropdown(
686
- choices=["Perfect","Defected","Unknown"],
687
- label="Class Label", value="Perfect", allow_custom_value=True)
688
- add_btn = gr.Button("πŸ’Ύ Add to Cluster", variant="primary")
689
-
690
- with gr.Column(scale=1):
691
- add_status = gr.Textbox(label="Status", lines=6)
692
- add_roi_view = gr.Image(label="Processed ROI", interactive=False)
693
-
694
- add_btn.click(
695
- fn=add_sample,
696
- inputs=[template_input, class_name_input],
697
- outputs=[add_status, add_roi_view],
698
- api_name="add_sample",
699
- )
700
 
701
- # ── Tab 3 β€” Bulk Train ────────────────────────────────────────────────────
702
- with gr.Tab("πŸ“¦ Train β€” Bulk Upload"):
703
- gr.Markdown("""
704
- ### Bulk Upload
705
- Select **multiple images** at once.
706
- All images are assigned to the chosen class label.
707
- The system will skip any image where bolt holes cannot be detected.
708
- """)
709
  with gr.Row():
710
- with gr.Column(scale=1):
711
- bulk_files = gr.File(
712
- label="Select Images (jpg / png / bmp β€” multiple OK)",
713
- file_count="multiple",
714
- file_types=["image"],
715
- )
716
- bulk_class = gr.Dropdown(
717
- choices=["Perfect","Defected","Unknown"],
718
- label="Class Label", value="Perfect", allow_custom_value=True)
719
- bulk_btn = gr.Button("πŸ“¦ Add All to Cluster", variant="primary")
720
-
721
- with gr.Column(scale=1):
722
- bulk_summary = gr.Markdown(label="Summary")
723
- bulk_log = gr.Textbox(label="Per-Image Log", lines=14,
724
- max_lines=30, interactive=False)
725
- bulk_roi_view = gr.Image(label="Last Processed ROI",
726
- interactive=False)
727
-
728
- bulk_btn.click(
729
- fn=add_bulk,
730
- inputs=[bulk_files, bulk_class],
731
- outputs=[bulk_summary, bulk_log, bulk_roi_view],
732
- api_name="add_bulk",
733
- )
734
-
735
- # ── Tab 4 β€” CLAHE Preview ─────────────────────────────────────────────────
 
 
 
 
 
736
  with gr.Tab("🎨 CLAHE Preview"):
737
- gr.Markdown("""
738
- ### Enhancement Preview
739
- Upload any image to see the **before / after** of the multi-stage CLAHE
740
- pipeline (homomorphic filter β†’ CLAHE β†’ bilateral denoise β†’ unsharp mask).
741
- Use this to verify the enhancement looks correct for your camera/lighting.
742
- """)
743
  with gr.Row():
744
- with gr.Column(scale=1):
745
- clahe_input = gr.Image(sources=["upload"], type="numpy",
746
- label="Input Image")
747
- clahe_btn = gr.Button("🎨 Preview Enhancement", variant="secondary")
748
  with gr.Column(scale=2):
749
- clahe_output = gr.Image(label="Original | Enhanced", interactive=False)
750
-
751
- clahe_btn.click(
752
- fn=clahe_preview,
753
- inputs=[clahe_input],
754
- outputs=[clahe_output],
755
- )
756
 
757
- # ── Tab 5 β€” Class Library ─────────────────────────────────────────────────
758
  with gr.Tab("πŸ“‹ Class Library"):
759
  with gr.Row():
760
- with gr.Column(scale=1):
761
- template_list = gr.Textbox(label="Trained Classes", lines=14)
762
- refresh_btn = gr.Button("πŸ”„ Refresh", variant="secondary")
763
-
764
- with gr.Column(scale=1):
765
- library_roi_view = gr.Image(label="Last Reference ROI",
766
- interactive=False)
767
  gr.Markdown("### ⚠️ Danger Zone")
768
  with gr.Row():
769
- delete_name = gr.Dropdown(
770
- choices=["Perfect","Defected","Unknown"],
771
- label="Class to Delete", allow_custom_value=True)
772
- delete_btn = gr.Button("πŸ—‘οΈ Delete Class", variant="stop")
773
- delete_status = gr.Textbox(label="Delete Status", lines=2)
774
-
775
- reset_btn = gr.Button("πŸ’₯ Reset ALL Classes", variant="stop")
776
- reset_status = gr.Textbox(label="Reset Status", lines=2)
777
-
778
- refresh_btn.click(
779
- fn=update_library_preview,
780
- outputs=[template_list, library_roi_view],
781
- )
782
- delete_btn.click(
783
- fn=delete_class_ui,
784
- inputs=[delete_name],
785
- outputs=[delete_status, template_list, library_roi_view],
786
- )
787
- reset_btn.click(
788
- fn=reset_all_ui,
789
- outputs=[reset_status, template_list, library_roi_view],
790
- )
791
- demo.load(fn=update_library_preview, outputs=[template_list, library_roi_view])
792
 
793
- gr.Markdown("""
794
- ---
795
- <div class="footer">
796
- Engine Part CV System β€’ PyTorch + OpenCV β€’ Multi-stage CLAHE + Centroid Matching
797
- </div>
798
- """)
799
 
800
  if __name__ == "__main__":
801
- demo.launch(share=False, show_error=True)
 
 
 
 
 
 
1
+ """
2
+ Engine Part CV System β€” v5
3
+ ══════════════════════════
4
+ Key upgrades over v4:
5
+ 1. PCA projection β€” compresses 1536-D CNN space to N most discriminative
6
+ dimensions, so cosine gaps widen from 0.007 β†’ 0.1+
7
+ 2. Anomaly scoring β€” primary signal is "distance from Perfect centroid"
8
+ rather than multi-class cosine race
9
+ 3. Per-dim variance weighting (whitening) β€” equalises feature scales
10
+ 4. Mahalanobis-style distance β€” accounts for within-class spread per axis
11
+ 5. Gradio 6.0 fix β€” theme/css moved to launch()
12
+ """
13
+
14
  import gradio as gr
15
  import cv2
16
  import numpy as np
 
20
  import torch
21
  from torchvision import models, transforms
22
  from PIL import Image
23
+ from sklearn.decomposition import PCA
24
+ from sklearn.preprocessing import StandardScaler
25
 
26
  logging.basicConfig(level=logging.INFO)
27
  logger = logging.getLogger(__name__)
 
30
  # CONSTANTS
31
  # ───────────────────────────────────────────────────────────────────────────────
32
 
33
+ TEMPLATE_FILE = "templates_v5.pkl"
34
+ CLUSTER_VERSION = "v5"
35
+ TEXTURE_WEIGHT = 1.6
36
+ MIN_SAMPLES_WARN = 5
37
+ MIN_MATCH_SAMPLES= 3
38
+ PCA_COMPONENTS = 64 # reduce 1536-D β†’ 64-D (tune: 32–128)
39
+ ANOMALY_THRESHOLD= 2.5 # mahalanobis z-score above this β†’ FAIL
40
+ PERFECT_CLASS = "Perfect"
41
 
42
 
43
  # ───────────────────────────────────────────────────────────────────────────────
44
+ # MULTI-STAGE CLAHE
45
  # ───────────────────────────────────────────────────────────────────────────────
46
 
47
  class CLAHEProcessor:
48
+ CLAHE_CLIP_LIMIT = 3.0
49
+ CLAHE_TILE_SIZE = (8, 8)
50
+ BILATERAL_D = 9
51
+ BILATERAL_SIGMA_C = 75
52
+ BILATERAL_SIGMA_S = 75
53
+ UNSHARP_STRENGTH = 0.6
 
 
54
 
55
  @classmethod
56
  def process(cls, rgb: np.ndarray) -> np.ndarray:
57
+ # Stage 1 β€” homomorphic illumination removal
58
+ lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
59
+ l, a, b = cv2.split(lab)
60
+ l_f = np.float64(l) + 1.0
61
+ l_log = np.log(l_f)
62
+ illum = cv2.GaussianBlur(l_log, (31, 31), 0)
63
+ reflect = cv2.normalize(l_log - illum, None, 0, 255, cv2.NORM_MINMAX)
64
+ l_homo = np.uint8(reflect)
65
+
66
+ # Stage 2 β€” adaptive CLAHE
67
+ clahe = cv2.createCLAHE(clipLimit=cls.CLAHE_CLIP_LIMIT,
68
+ tileGridSize=cls.CLAHE_TILE_SIZE)
69
+ l_clahe = clahe.apply(l_homo)
70
+
71
+ # Stage 3 β€” bilateral denoise
72
+ lab_c = cv2.merge((l_clahe, a, b))
73
+ rgb_c = cv2.cvtColor(lab_c, cv2.COLOR_LAB2RGB)
74
+ bgr_den = cv2.bilateralFilter(
75
+ cv2.cvtColor(rgb_c, cv2.COLOR_RGB2BGR),
76
+ cls.BILATERAL_D, cls.BILATERAL_SIGMA_C, cls.BILATERAL_SIGMA_S)
77
+ rgb_den = cv2.cvtColor(bgr_den, cv2.COLOR_BGR2RGB)
78
+
79
+ # Stage 4 β€” unsharp mask
80
+ blur = cv2.GaussianBlur(rgb_den, (5, 5), 0)
81
+ sharp = cv2.addWeighted(rgb_den, 1.0 + cls.UNSHARP_STRENGTH,
82
+ blur, -cls.UNSHARP_STRENGTH, 0)
83
+ return np.clip(sharp, 0, 255).astype(np.uint8)
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  @classmethod
86
  def preview(cls, rgb: np.ndarray) -> np.ndarray:
87
+ enh = cls.process(rgb)
88
+ h = max(rgb.shape[0], enh.shape[0])
89
+ o_r = cv2.resize(rgb, (rgb.shape[1], h))
90
+ e_r = cv2.resize(enh, (enh.shape[1], h))
91
+ def _lbl(img, txt):
 
 
 
92
  out = img.copy()
93
+ cv2.putText(out, txt, (10,30), cv2.FONT_HERSHEY_SIMPLEX,
94
+ 0.9, (255,255,0), 2, cv2.LINE_AA)
95
  return out
96
+ return np.hstack([_lbl(o_r,"Original"), _lbl(e_r,"Enhanced")])
 
97
 
98
 
99
  # ───────────────────────────────────────────────────────────────────────────────
 
101
  # ───────────────────────────────────────────────────────────────────────────────
102
 
103
  class FeatureExtractor:
 
104
  def __init__(self):
105
+ self.backbone = models.resnet50(weights="IMAGENET1K_V1")
106
  self.backbone.eval()
107
  self.transform = transforms.Compose([
108
+ transforms.Resize((224,224)),
109
  transforms.ToTensor(),
110
  transforms.Normalize(mean=[0.485,0.456,0.406],
111
  std =[0.229,0.224,0.225]),
112
  ])
113
 
114
  @staticmethod
115
+ def _texture(gray: np.ndarray) -> np.ndarray:
116
+ feats = []
117
+ g = gray.astype(np.float64)
118
+ gx = cv2.Sobel(g, cv2.CV_64F, 1, 0, ksize=3)
119
+ gy = cv2.Sobel(g, cv2.CV_64F, 0, 1, ksize=3)
120
+ mag = np.sqrt(gx**2 + gy**2)
121
+ ang = np.arctan2(gy, gx)
122
+
123
+ mh,_ = np.histogram(mag, bins=32, density=True); feats.extend(mh)
124
+ ah,_ = np.histogram(ang, bins=36, range=(-np.pi,np.pi), density=True)
125
+ feats.extend(ah)
126
+
127
+ h,w = gray.shape
128
+ ph,pw = max(1,h//4), max(1,w//4)
 
 
129
  for i in range(4):
130
  for j in range(4):
131
  p = gray[i*ph:(i+1)*ph, j*pw:(j+1)*pw]
132
  if p.size == 0:
133
+ feats.extend([0.]*4); continue
134
  pf = p.astype(np.float64)
135
+ feats.append(float(np.std(pf)))
136
+ hp,_ = np.histogram(p,bins=32,range=(0,256),density=True)
137
+ hp = hp[hp>0]
138
+ feats.append(-float(np.sum(hp*np.log2(hp+1e-10))))
139
+ feats.append(float(np.mean(cv2.Canny(p,50,150))/255.))
140
+ gxp = cv2.Sobel(pf,cv2.CV_64F,1,0,ksize=3)
141
+ gyp = cv2.Sobel(pf,cv2.CV_64F,0,1,ksize=3)
142
+ feats.append(float(np.mean(np.sqrt(gxp**2+gyp**2))))
143
 
144
  for theta in [0, np.pi/4, np.pi/2, 3*np.pi/4]:
145
+ for sigma in [3., 5.]:
146
+ k = cv2.getGaborKernel((21,21),sigma,theta,10.,0.5,0,ktype=cv2.CV_64F)
147
  f = cv2.filter2D(g, cv2.CV_64F, k)
148
+ feats.extend([float(np.mean(f)), float(np.std(f))])
149
 
150
+ return np.array(feats, dtype=np.float64)
151
 
152
+ def extract_raw(self, rgb) -> tuple:
153
+ """Return raw (un-projected) feature vector + attention overlay."""
154
  if isinstance(rgb, Image.Image):
155
  rgb = np.array(rgb.convert("RGB"))
156
+ rgb = rgb.astype(np.uint8)
 
157
  if len(rgb.shape) == 2:
158
  rgb = cv2.cvtColor(rgb, cv2.COLOR_GRAY2RGB)
159
 
 
160
  rgb_enh = CLAHEProcessor.process(rgb)
161
 
 
162
  t = self.transform(Image.fromarray(rgb_enh)).unsqueeze(0)
163
  with torch.no_grad():
164
+ x = self.backbone.maxpool(self.backbone.relu(
165
+ self.backbone.bn1(self.backbone.conv1(t))))
 
 
166
  x = self.backbone.layer1(x)
167
  fl2 = self.backbone.layer2(x)
168
  fl3 = self.backbone.layer3(fl2)
169
+ c2 = torch.mean(fl2,dim=[2,3]).squeeze().cpu().numpy()
170
+ c3 = torch.mean(fl3,dim=[2,3]).squeeze().cpu().numpy()
171
 
172
+ amap = torch.sum(fl3,dim=1).squeeze().cpu().numpy()
173
+ amap = np.maximum(amap,0); amap /= (np.max(amap)+1e-8)
174
+ amap = cv2.resize(amap,(rgb.shape[1],rgb.shape[0]))
175
+ hm = cv2.applyColorMap(np.uint8(255*amap),cv2.COLORMAP_JET)
176
+ ov = cv2.addWeighted(rgb,0.6,
177
+ cv2.cvtColor(hm,cv2.COLOR_BGR2RGB),0.4,0)
178
 
179
+ gray_e = cv2.cvtColor(rgb_enh, cv2.COLOR_RGB2GRAY)
180
+ tex = self._texture(gray_e)
 
 
 
 
 
 
181
 
182
+ cnn = np.concatenate([c2,c3])
183
+ cn = np.linalg.norm(cnn); cu = cnn/cn if cn>1e-8 else cnn
184
+ tn = np.linalg.norm(tex); tu = tex/tn if tn>1e-8 else tex
185
+ raw = np.concatenate([cu, tu*TEXTURE_WEIGHT])
186
+ n = np.linalg.norm(raw)
187
+ return (raw/n if n>1e-8 else raw), ov
188
 
 
 
 
 
189
 
190
+ # ─────────────────────────────────────────��─────────────────────────────────────
191
+ # PCA PROJECTOR β€” the key fix for cosine collapse
192
+ # ───────────────────────────────────────────────────────────────────────────────
193
 
194
+ class PCAProjector:
195
+ """
196
+ Fits a PCA on ALL stored feature vectors across ALL classes, then
197
+ projects every query into the lower-dimensional discriminative subspace.
198
+
199
+ Why this fixes the 0.9944 / 0.9865 cosine collapse
200
+ ───────────────────────────────────────────────────
201
+ In 1536-D space virtually every unit vector has cosine similarity β‰₯ 0.98
202
+ to every other β€” this is the "curse of dimensionality". PCA finds the
203
+ axes of maximum variance in the training data. These axes correspond
204
+ to the visual differences BETWEEN classes (colour, texture, defect edges).
205
+ After projecting to 64-D the cosine gap between Perfect and Defected
206
+ typically widens from 0.007 β†’ 0.10–0.30, making classification reliable.
207
+ """
208
+
209
+ def __init__(self, n_components: int = PCA_COMPONENTS):
210
+ self.n_components = n_components
211
+ self.pca = None
212
+ self.scaler = None
213
+ self.fitted = False
214
+
215
+ def fit(self, all_vectors: list[np.ndarray]) -> None:
216
+ if len(all_vectors) < self.n_components + 1:
217
+ logger.warning("Not enough vectors to fit PCA yet.")
218
+ return
219
+ X = np.array(all_vectors) # (N, D)
220
+ self.scaler = StandardScaler()
221
+ Xs = self.scaler.fit_transform(X)
222
+ n_comp = min(self.n_components, Xs.shape[0]-1, Xs.shape[1])
223
+ self.pca = PCA(n_components=n_comp, svd_solver="full")
224
+ self.pca.fit(Xs)
225
+ var_exp = np.sum(self.pca.explained_variance_ratio_) * 100
226
+ logger.info(f"PCA fitted: {n_comp} components, {var_exp:.1f}% variance explained.")
227
+ self.fitted = True
228
+
229
+ def project(self, vec: np.ndarray) -> np.ndarray:
230
+ if not self.fitted:
231
+ return vec
232
+ xs = self.scaler.transform(vec.reshape(1,-1))
233
+ out = self.pca.transform(xs).squeeze()
234
+ n = np.linalg.norm(out)
235
+ return out/n if n>1e-8 else out
236
+
237
+ def project_many(self, vecs: list[np.ndarray]) -> np.ndarray:
238
+ if not self.fitted:
239
+ return np.array(vecs)
240
+ X = np.array(vecs)
241
+ Xs = self.scaler.transform(X)
242
+ out = self.pca.transform(Xs)
243
+ norms = np.linalg.norm(out, axis=1, keepdims=True)
244
+ return out / np.where(norms>1e-8, norms, 1.0)
245
 
246
 
247
  # ───────────────────────────────────────────────────────────────────────────────
 
251
  class EnginePartDetector:
252
 
253
  def __init__(self):
254
+ self.fe = FeatureExtractor()
255
+ self.projector = PCAProjector(PCA_COMPONENTS)
256
+
257
+ # raw feature storage (used to refit PCA when new samples arrive)
258
+ self.classes: dict[str, list[np.ndarray]] = {} # raw vectors
259
+ # projected centroids + stats (rebuilt after every PCA refit)
260
  self.centroids: dict[str, np.ndarray] = {}
261
  self.class_spread: dict[str, float] = {}
262
+ self.class_cov_inv:dict[str, np.ndarray] = {} # for mahalanobis
263
  self.class_rois: dict[str, np.ndarray] = {}
264
  self._load_data()
265
 
266
+ # ── Centroid / covariance helpers ─────────────────────────────────────────
267
+
268
+ def _refit_pca_and_centroids(self) -> None:
269
+ """Call after any class modification β€” keeps PCA up to date."""
270
+ all_vecs = [v for vecs in self.classes.values() for v in vecs]
271
+ if len(all_vecs) >= PCA_COMPONENTS + 1:
272
+ self.projector.fit(all_vecs)
273
+ self._rebuild_all_centroids()
274
+
275
+ def _rebuild_all_centroids(self) -> None:
276
+ for name in self.classes:
277
+ self._compute_centroid(name)
278
 
279
  def _compute_centroid(self, name: str) -> None:
280
+ raw_vecs = self.classes[name]
281
+ if self.projector.fitted:
282
+ vecs = self.projector.project_many(raw_vecs) # (N, K)
283
+ else:
284
+ vecs = np.array(raw_vecs)
285
+
286
  centroid = np.mean(vecs, axis=0)
287
+ n = np.linalg.norm(centroid)
288
+ self.centroids[name] = centroid/n if n>1e-8 else centroid
289
+
290
  if len(vecs) > 1:
291
+ dists = [float(np.linalg.norm(v - centroid)) for v in vecs]
292
  self.class_spread[name] = float(np.std(dists)) + 1e-6
293
  else:
294
  self.class_spread[name] = 1.0
 
 
295
 
296
+ # Per-axis covariance for Mahalanobis (diagonal approx for speed)
297
+ if len(vecs) >= 4:
298
+ var = np.var(vecs, axis=0) + 1e-6
299
+ self.class_cov_inv[name] = 1.0 / var # diagonal inverse
300
+ else:
301
+ self.class_cov_inv[name] = None
302
 
303
  # ── Persistence ───────────────────────────────────────────────────────────
304
 
 
308
  pickle.dump({
309
  "version": CLUSTER_VERSION,
310
  "texture_weight": TEXTURE_WEIGHT,
311
+ "pca_components": PCA_COMPONENTS,
312
  "classes": self.classes,
313
  "rois": self.class_rois,
314
+ "projector": self.projector,
315
  }, f)
316
  except Exception as e:
317
  logger.error(f"Save failed: {e}")
 
320
  if not os.path.exists(TEMPLATE_FILE):
321
  return
322
  try:
323
+ with open(TEMPLATE_FILE,"rb") as f:
324
  data = pickle.load(f)
325
  if (data.get("version") != CLUSTER_VERSION or
326
+ data.get("texture_weight") != TEXTURE_WEIGHT or
327
+ data.get("pca_components") != PCA_COMPONENTS):
328
  logger.warning("Stale cluster file β€” discarding.")
329
+ os.remove(TEMPLATE_FILE); return
330
+
331
+ self.classes = data.get("classes", {})
332
+ self.class_rois = data.get("rois", {})
333
+ self.projector = data.get("projector", PCAProjector(PCA_COMPONENTS))
334
  self._rebuild_all_centroids()
335
  logger.info(f"Loaded {len(self.classes)} class(es).")
336
  except Exception as e:
 
340
  # ── Layer 1 β€” ROI localisation ────────────────────────────────────────────
341
 
342
  @staticmethod
343
+ def detect_and_crop(img_rgb: np.ndarray) -> tuple:
 
344
  img_h, img_w = img_rgb.shape[:2]
345
+ gray = cv2.GaussianBlur(
346
+ cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY),(7,7),0)
347
+ sc = img_w / 1000.0
348
+ circles = cv2.HoughCircles(
 
349
  gray, cv2.HOUGH_GRADIENT, dp=1.2,
350
+ minDist=max(30,int(60*sc)), param1=100, param2=35,
351
+ minRadius=max(5,int(12*sc)), maxRadius=max(20,int(45*sc)))
352
+
 
 
353
  if circles is None:
354
  return img_rgb, img_rgb, "❌ No bolt holes detected."
355
 
356
  circles = np.round(circles[0]).astype(int)
357
+ ys = [c[1] for c in circles]
358
  y_med = np.median(ys)
359
+ top_row = sorted([c for c in circles if c[1]<y_med], key=lambda x:x[0])
360
+ bot_row = sorted([c for c in circles if c[1]>=y_med], key=lambda x:x[0])
361
 
362
+ if len(top_row)<2 or len(bot_row)<2:
363
  return img_rgb, img_rgb, "⚠️ Insufficient hole rows."
364
 
365
+ y_top = int(np.mean([c[1] for c in top_row]))
366
+ y_bot = int(np.mean([c[1] for c in bot_row]))
367
+ xs = [c[0] for c in circles]
368
+ x0 = max(0, min(xs)-60); x1 = min(img_w, max(xs)+60)
369
+ y0 = max(0, min(y_top,y_bot)-20)
370
+ y1 = min(img_h, max(y_top,y_bot)+20)
 
371
 
372
  vis = img_rgb.copy()
373
+ cv2.line(vis,(0,y_top),(img_w,y_top),(0,255,0),3)
374
+ cv2.line(vis,(0,y_bot),(img_w,y_bot),(0,255,0),3)
375
+ for (x,y,r) in circles:
376
+ cv2.circle(vis,(x,y),r,(255,0,0),3)
377
+ cv2.circle(vis,(x,y),2,(255,255,255),-1)
378
 
379
+ crop = img_rgb[y0:y1, x0:x1]
380
  if crop.size == 0:
381
  return vis, img_rgb, "⚠️ ROI crop failed."
382
 
383
+ stats = (f"βœ… ROI: {len(circles)} holes | "
384
  f"{len(top_row)} top, {len(bot_row)} bottom | "
385
+ f"{crop.shape[1]}Γ—{crop.shape[0]} px")
386
  return vis, crop, stats
387
 
388
  # ── Internal helpers ──────────────────────────────────────────────────────
389
 
390
  @staticmethod
391
+ def _cosine(a,b) -> float:
392
+ na,nb = np.linalg.norm(a), np.linalg.norm(b)
393
+ return float(np.dot(a,b)/(na*nb)) if na>1e-8 and nb>1e-8 else 0.
394
+
395
+ def _mahalanobis(self, query: np.ndarray, name: str) -> float:
396
+ """
397
+ Diagonal Mahalanobis distance from query to class centroid.
398
+ Accounts for per-axis spread β€” features with high variance within
399
+ a class contribute less to the distance score.
400
+ """
401
+ centroid = self.centroids[name]
402
+ cov_inv = self.class_cov_inv.get(name)
403
+ diff = query - centroid
404
+ if cov_inv is not None:
405
+ return float(np.sqrt(np.dot(diff**2, cov_inv)))
406
+ else:
407
+ return float(np.linalg.norm(diff))
408
+
409
+ def _anomaly_score(self, query_proj: np.ndarray) -> dict:
410
+ """
411
+ Primary decision signal: z-score distance from the Perfect centroid.
412
+ Lower = more like a Perfect part.
413
+ Returns dict with anomaly_z, perfect_dist, verdict.
414
+ """
415
+ if PERFECT_CLASS not in self.centroids:
416
+ return {"anomaly_z": None, "verdict": "no_perfect_class"}
417
+
418
+ dist = self._mahalanobis(query_proj, PERFECT_CLASS)
419
+ spread = self.class_spread.get(PERFECT_CLASS, 1.0)
420
+ z = dist / (spread + 1e-8)
421
+ return {"anomaly_z": z, "perfect_dist": dist, "spread": spread,
422
+ "verdict": "pass" if z < ANOMALY_THRESHOLD else "fail"}
423
 
424
  # ── Public API β€” single image ─────────────────────────────────────────────
425
 
426
  def add_to_class(self, image: np.ndarray, class_name: str) -> tuple:
427
+ if image is None: return "❌ No image supplied.", None
428
+ if not class_name.strip(): return "❌ Class name empty.", None
 
 
429
 
430
  class_name = class_name.strip()
431
+ vis, roi, log = self.detect_and_crop(image)
432
  if "❌" in log or "⚠️" in log:
433
  return log, None
434
 
435
+ raw, _ = self.fe.extract_raw(roi)
436
+ if class_name not in self.classes:
437
+ self.classes[class_name] = []
438
+ self.classes[class_name].append(raw)
439
+
440
+ self.class_rois[class_name] = CLAHEProcessor.process(roi)
441
+ self._refit_pca_and_centroids()
442
  self._persist_data()
443
 
444
  n = len(self.classes[class_name])
445
+ pca_note = (f" PCA fitted on {sum(len(v) for v in self.classes.values())} "
446
+ f"total vectors β†’ {PCA_COMPONENTS}-D."
447
+ if self.projector.fitted else
448
+ f" ⚠️ Need {PCA_COMPONENTS+1} total samples to activate PCA.")
449
+ warn = (f"\n⚠️ Only {n} sample(s) for '{class_name}'. "
450
+ f"Add β‰₯{MIN_SAMPLES_WARN}." if n<MIN_SAMPLES_WARN else "")
451
+ return (f"βœ… Added to '{class_name}' ({n} sample(s)){warn}\n"
452
+ f"{pca_note}\n{log}"), roi
453
+
454
+ # ── Public API β€” bulk upload ──────────────────────────────────────────────
455
+
456
+ def add_bulk_to_class(self, file_paths, class_name, progress_cb=None) -> tuple:
457
+ if not file_paths: return "❌ No files.", [], None
458
+ if not class_name.strip(): return "❌ Class name empty.", [], None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
 
460
  class_name = class_name.strip()
461
+ total, ok, fail = len(file_paths), 0, 0
462
+ log_lines, last_roi = [], None
 
 
 
463
 
464
  for idx, fp in enumerate(file_paths):
465
+ path = fp if isinstance(fp,str) else fp.get("name",str(fp))
 
466
  fname = os.path.basename(path)
 
467
  try:
468
+ image = np.array(Image.open(path).convert("RGB"))
 
469
  except Exception as e:
470
  log_lines.append(f"❌ [{idx+1}/{total}] {fname} β€” load error: {e}")
471
+ fail += 1; continue
 
472
 
473
+ vis, roi, loc = self.detect_and_crop(image)
474
+ if "❌" in loc or "⚠️" in loc:
475
+ log_lines.append(f"⚠️ [{idx+1}/{total}] {fname} β€” {loc}")
476
+ fail += 1; continue
 
 
477
 
478
  try:
479
+ raw, _ = self.fe.extract_raw(roi)
480
+ if class_name not in self.classes:
481
+ self.classes[class_name] = []
482
+ self.classes[class_name].append(raw)
483
+ last_roi = roi; ok += 1
484
+ log_lines.append(f"βœ… [{idx+1}/{total}] {fname}")
485
  except Exception as e:
486
+ log_lines.append(f"❌ [{idx+1}/{total}] {fname} β€” {e}")
487
+ fail += 1
488
 
489
+ if progress_cb: progress_cb(idx+1, total)
 
490
 
491
+ if ok > 0:
492
+ self.class_rois[class_name] = CLAHEProcessor.process(last_roi)
493
+ self._refit_pca_and_centroids()
494
  self._persist_data()
495
 
496
+ n = len(self.classes.get(class_name,[]))
497
+ pca = (f"PCA active: {PCA_COMPONENTS}-D projection."
498
+ if self.projector.fitted else
499
+ f"PCA pending: need {max(0,PCA_COMPONENTS+1 - sum(len(v) for v in self.classes.values()))} more total samples.")
 
500
  summary = (
501
+ f"### Bulk Upload\n"
502
+ f"- **Class**: `{class_name}` | **Total**: {total} | "
503
+ f"βœ… {ok} ❌ {fail}\n"
504
+ f"- **'{class_name}' total samples**: {n}\n"
505
+ f"- {pca}"
 
 
506
  )
507
  return summary, log_lines, last_roi
508
 
 
510
 
511
  def match_part(self, image: np.ndarray, threshold: float = 0.75) -> tuple:
512
  if image is None:
513
+ return "❌ No image.", None, None, None, None
514
  if not self.classes:
515
+ return ("⚠️ No classes trained yet.", None, None, None, None)
 
516
 
517
+ vis, roi, log = self.detect_and_crop(image)
518
  if "❌" in log or "⚠️" in log:
519
+ return f"❌ {log}", None, vis, None, None
520
 
521
+ raw_feat, attn_map = self.fe.extract_raw(roi)
522
 
523
+ # ── Project to PCA space ──────────────────────────────────────────────
524
+ if self.projector.fitted:
525
+ q = self.projector.project(raw_feat)
526
+ pca_note = f"βœ… PCA active ({PCA_COMPONENTS}-D projection)"
527
+ else:
528
+ q = raw_feat
529
+ total_needed = PCA_COMPONENTS + 1
530
+ total_have = sum(len(v) for v in self.classes.values())
531
+ pca_note = (f"⚠️ PCA not yet fitted β€” need "
532
+ f"{total_needed - total_have} more total samples. "
533
+ f"Results may be unreliable.")
534
+
535
+ # ── Anomaly score (primary signal) ────────────────────────────────────
536
+ anomaly = self._anomaly_score(q)
537
+
538
+ # ── Centroid cosine scoring (secondary signal) ────────────────────────
539
+ eligible = {n:c for n,c in self.centroids.items()
540
  if len(self.classes[n]) >= MIN_MATCH_SAMPLES}
541
  skipped = [n for n in self.classes if n not in eligible]
542
 
543
  if not eligible:
544
+ return (f"⚠️ No class has β‰₯{MIN_MATCH_SAMPLES} samples.", None, vis, None, None)
 
545
 
546
+ # Cosine + spread penalty
547
  class_scores = []
548
  for name, centroid in eligible.items():
549
+ cos = self._cosine(q, centroid)
550
+ spread = self.class_spread.get(name, 1.0)
551
+ adj = cos / (1.0 + spread)
552
+ class_scores.append((name, adj, cos))
553
+
554
+ class_scores.sort(key=lambda x:x[1], reverse=True)
555
+ best_name, best_adj, best_cos = class_scores[0]
556
+ second_adj = class_scores[1][1] if len(class_scores)>1 else 0.
557
+ cosine_gap = best_adj - second_adj
 
 
 
 
 
 
558
 
559
  # ── Balance weight (imbalance correction) ─────────────────────────────
560
+ TEMPERATURE = 0.05
561
+ adj_arr = np.array([s[1] for s in class_scores])
562
+ exp_s = np.exp((adj_arr - np.max(adj_arr)) / TEMPERATURE)
563
+ probs = exp_s / np.sum(exp_s)
564
+ total_s = sum(len(self.classes[n]) for n in eligible)
565
+ n_cls = len(eligible)
566
+
567
+ weighted = []
568
+ for (name, adj, cos), p in zip(class_scores, probs):
569
+ w = total_s / (n_cls * len(self.classes[name]))
570
+ weighted.append((name, p*w, cos))
571
+
572
+ total_w = sum(x[1] for x in weighted)
573
+ class_probs= [(n, p/total_w, c) for n,p,c in weighted]
574
+ class_probs.sort(key=lambda x:x[1], reverse=True)
575
+
576
+ top_class = class_probs[0][0]
577
+ top_prob = class_probs[0][1]
578
+
579
+ # ── Final verdict β€” anomaly score overrides if Perfect class exists ───
580
+ az = anomaly.get("anomaly_z")
581
+ if az is not None:
582
+ if az < ANOMALY_THRESHOLD:
583
+ final_status = "βœ… PASS β€” surface matches Perfect cluster"
584
+ verdict_class = PERFECT_CLASS
585
+ else:
586
+ # Among non-Perfect classes, pick the highest scoring
587
+ non_perfect = [(n,p,c) for n,p,c in class_probs
588
+ if n.lower() != "perfect"]
589
+ if non_perfect:
590
+ verdict_class = non_perfect[0][0]
591
+ else:
592
+ verdict_class = top_class
593
+ final_status = f"❌ FAIL β€” anomaly detected ({verdict_class})"
594
  else:
595
+ # No Perfect class β†’ fall back to cosine winner
596
+ verdict_class = top_class
597
+ if "perfect" in top_class.lower():
598
+ final_status = "βœ… PASS" if top_prob >= threshold else "❓ UNCERTAIN"
599
+ else:
600
+ final_status = f"❌ FAIL β€” {verdict_class}"
601
+
602
+ # ── Build report ──────────────────────────────────────────────────────
603
+ az_bar = ""
604
+ if az is not None:
605
+ filled = int(min(az / (ANOMALY_THRESHOLD * 1.5), 1.0) * 20)
606
+ az_bar = "β–ˆ"*filled + "β–‘"*(20-filled)
607
+ az_bar = f"`[{az_bar}]` {az:.2f} (threshold: {ANOMALY_THRESHOLD})"
608
 
609
  lines = [
610
+ f"## {final_status}",
611
+ "",
612
+ "### πŸ”¬ Anomaly Score (primary signal)",
613
+ f"Distance from Perfect cluster: {az_bar}" if az_bar else "*(No Perfect class trained)*",
 
614
  "",
615
+ "### πŸ“Š Class Probabilities (PCA cosine, secondary signal)",
616
  ]
617
+ for name, prob, cos in class_probs:
618
+ marker = "πŸ‘‰ " if name == verdict_class else " "
619
+ lines.append(f"{marker}`{name}`: **{prob:.1%}** (cosine: {cos:.4f})")
620
 
621
  lines += [
622
+ "",
623
+ f"**Cosine gap**: {cosine_gap:.4f} | {pca_note}",
624
+ "",
625
  "### Pipeline",
626
+ "1. ROI localisation 2. CLAHE 3. ResNet-50 features",
627
+ "4. PCA projection 5. Anomaly z-score + centroid cosine",
628
+ "---", log,
 
 
 
629
  ]
630
+ if skipped:
631
+ lines.append(f"\n⚠️ Skipped (too few samples): {', '.join(skipped)}")
 
632
 
633
+ label_dict = {n: float(p) for n,p,_ in class_probs}
634
 
635
  roi_e = CLAHEProcessor.process(roi)
636
  gray_e = cv2.cvtColor(roi_e, cv2.COLOR_RGB2GRAY)
637
+ edges = cv2.cvtColor(cv2.Canny(gray_e,50,150), cv2.COLOR_GRAY2RGB)
638
 
639
+ return "\n".join(lines), label_dict, vis, attn_map, edges
640
 
641
  # ── Utility ───────────────────────────────────────────────────────────────
642
 
643
+ def get_template_roi(self, name):
644
+ return self.class_rois.get(name)
645
 
646
  def list_templates(self) -> str:
647
+ if not self.classes: return "No classes trained yet."
 
 
 
 
648
  total = sum(len(v) for v in self.classes.values())
649
+ pca_s = (f"PCA: βœ… active ({PCA_COMPONENTS}-D)"
650
+ if self.projector.fitted else
651
+ f"PCA: ⏳ need {max(0,PCA_COMPONENTS+1-total)} more samples")
652
+ lines = [f"Classes: {len(self.classes)} | Samples: {total} | {pca_s}",
653
+ f"Version: {CLUSTER_VERSION}", "─"*45]
654
  for name, vecs in sorted(self.classes.items()):
655
  pct = 100*len(vecs)/total if total else 0
656
+ warn = f" ⚠️ need {MIN_SAMPLES_WARN-len(vecs)} more" if len(vecs)<MIN_SAMPLES_WARN else ""
657
+ spread = self.class_spread.get(name, 0)
658
+ lines.append(f" β€’ {name}: {len(vecs)} samples ({pct:.0f}%) spread={spread:.4f}{warn}")
659
  return "\n".join(lines)
660
 
661
+ def delete_class(self, name: str) -> bool:
662
+ if name in self.classes:
663
+ del self.classes[name]
664
+ for d in [self.centroids, self.class_spread, self.class_cov_inv, self.class_rois]:
665
+ d.pop(name, None)
666
+ self._refit_pca_and_centroids()
667
  self._persist_data()
668
  return True
669
  return False
670
 
671
  def reset_all(self) -> str:
672
+ self.classes={}; self.centroids={}; self.class_spread={}
673
+ self.class_cov_inv={}; self.class_rois={}
674
+ self.projector = PCAProjector(PCA_COMPONENTS)
675
+ if os.path.exists(TEMPLATE_FILE): os.remove(TEMPLATE_FILE)
676
+ return "βœ… All classes cleared. PCA reset."
677
 
678
 
679
  # ───────────────────────────────────────────────────────────────────────────────
680
+ # GRADIO APPLICATION (Gradio 6.0 β€” theme/css in launch())
681
  # ───────────────────────────────────────────────────────────────────────────────
682
 
683
  detector = EnginePartDetector()
684
 
 
685
  def detect_part(image, threshold):
686
  return detector.match_part(image, threshold)
687
 
 
689
  return detector.add_to_class(image, class_name)
690
 
691
  def add_bulk(files, class_name, progress=gr.Progress()):
692
+ paths = [f.name if hasattr(f,"name") else f for f in (files or [])]
693
+ def cb(done, total): progress(done/total, desc=f"{done}/{total}")
 
 
 
694
  summary, log_lines, last_roi = detector.add_bulk_to_class(paths, class_name, cb)
695
+ return summary, "\n".join(log_lines), last_roi
 
696
 
697
  def clahe_preview(image):
698
+ return CLAHEProcessor.preview(image) if image is not None else None
 
 
699
 
700
  def update_library_preview():
701
  txt = detector.list_templates()
702
+ roi = detector.get_template_roi(sorted(detector.classes.keys())[0]) if detector.classes else None
703
+ return txt, roi
 
 
704
 
705
  def delete_class_ui(class_name):
706
  ok = detector.delete_class(class_name)
707
+ msg = f"βœ… Deleted '{class_name}'." if ok else f"❌ Not found."
708
  txt, roi = update_library_preview()
709
  return msg, txt, roi
710
 
711
  def reset_all_ui():
712
+ return detector.reset_all(), "No classes.", None
 
713
 
714
 
715
  custom_css = """
716
+ .header{text-align:center;margin-bottom:1.5rem;}
717
+ .footer{text-align:center;margin-top:1.5rem;color:#666;}
718
  """
719
 
720
+ with gr.Blocks(title="Engine Part CV System v5") as demo:
 
721
 
722
  gr.Markdown("""
723
  <div class="header">
724
+ <h1>πŸ”§ Engine Part CV System <code>v5</code></h1>
725
+ <p><strong>Pipeline:</strong>
726
+ ROI β†’ CLAHE β†’ ResNet-50 β†’ <b>PCA (64-D)</b> β†’ Anomaly Score + Centroid Cosine</p>
727
+ <p>⚠️ <em>Add β‰₯10 images per class. PCA activates after 65 total samples.</em></p>
 
 
 
728
  </div>
729
  """)
730
 
731
+ # ── Inspect ───────────────────────────────────────────────────────────────
732
  with gr.Tab("πŸ” Inspect Part"):
733
  with gr.Row():
734
+ with gr.Column():
735
+ det_img = gr.Image(sources=["upload","webcam"],
736
+ type="numpy", label="Input Image")
737
+ thresh = gr.Slider(0.50, 0.99, value=0.75, step=0.01,
738
+ label="Confidence Threshold")
739
+ det_btn = gr.Button("πŸ” Run Inspection", variant="primary")
740
+ with gr.Column():
741
+ det_out = gr.Markdown()
742
+ lbl_out = gr.Label(label="Class Probabilities", num_top_classes=5)
 
743
  with gr.Row():
744
+ vis_out = gr.Image(label="Field Visualisation")
745
+ attn_out = gr.Image(label="AI Attention Heatmap")
746
+ edge_out = gr.Image(label="Edge Map")
 
 
 
 
 
 
 
747
 
748
+ det_btn.click(detect_part, [det_img, thresh],
749
+ [det_out, lbl_out, vis_out, attn_out, edge_out],
750
+ api_name="detect_part")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
 
752
+ # ── Single train ──────────────────────────────────────────────────────────
753
+ with gr.Tab("πŸ’Ύ Train β€” Single"):
 
 
 
 
 
 
754
  with gr.Row():
755
+ with gr.Column():
756
+ s_img = gr.Image(sources=["upload"], type="numpy",
757
+ label="Training Image")
758
+ s_cls = gr.Dropdown(["Perfect","Defected","Unknown"],
759
+ value="Perfect", allow_custom_value=True,
760
+ label="Class")
761
+ s_btn = gr.Button("πŸ’Ύ Add", variant="primary")
762
+ with gr.Column():
763
+ s_stat = gr.Textbox(label="Status", lines=7)
764
+ s_roi = gr.Image(label="Processed ROI", interactive=False)
765
+ s_btn.click(add_sample,[s_img,s_cls],[s_stat,s_roi],api_name="add_sample")
766
+
767
+ # ── Bulk train ────────────────────────────────────────────────────────────
768
+ with gr.Tab("πŸ“¦ Train β€” Bulk"):
769
+ gr.Markdown("Select multiple images. All assigned to the chosen class.")
770
+ with gr.Row():
771
+ with gr.Column():
772
+ b_files = gr.File(label="Images", file_count="multiple",
773
+ file_types=["image"])
774
+ b_cls = gr.Dropdown(["Perfect","Defected","Unknown"],
775
+ value="Perfect", allow_custom_value=True,
776
+ label="Class")
777
+ b_btn = gr.Button("πŸ“¦ Add All", variant="primary")
778
+ with gr.Column():
779
+ b_sum = gr.Markdown()
780
+ b_log = gr.Textbox(label="Per-Image Log", lines=14,
781
+ max_lines=30, interactive=False)
782
+ b_roi = gr.Image(label="Last ROI", interactive=False)
783
+ b_btn.click(add_bulk,[b_files,b_cls],[b_sum,b_log,b_roi],api_name="add_bulk")
784
+
785
+ # ── CLAHE Preview ─────────────────────────────────────────────────────────
786
  with gr.Tab("🎨 CLAHE Preview"):
787
+ gr.Markdown("See before/after of the 4-stage CLAHE enhancement pipeline.")
 
 
 
 
 
788
  with gr.Row():
789
+ with gr.Column():
790
+ cp_in = gr.Image(sources=["upload"], type="numpy", label="Input")
791
+ cp_btn = gr.Button("🎨 Preview", variant="secondary")
 
792
  with gr.Column(scale=2):
793
+ cp_out = gr.Image(label="Original | Enhanced", interactive=False)
794
+ cp_btn.click(clahe_preview,[cp_in],[cp_out])
 
 
 
 
 
795
 
796
+ # ── Library ───────────────────────────────────────────────────────────────
797
  with gr.Tab("πŸ“‹ Class Library"):
798
  with gr.Row():
799
+ with gr.Column():
800
+ lib_txt = gr.Textbox(label="Trained Classes", lines=14)
801
+ ref_btn = gr.Button("πŸ”„ Refresh", variant="secondary")
802
+ with gr.Column():
803
+ lib_roi = gr.Image(label="Reference ROI", interactive=False)
 
 
804
  gr.Markdown("### ⚠️ Danger Zone")
805
  with gr.Row():
806
+ del_cls = gr.Dropdown(["Perfect","Defected","Unknown"],
807
+ allow_custom_value=True, label="Delete")
808
+ del_btn = gr.Button("πŸ—‘οΈ Delete", variant="stop")
809
+ del_st = gr.Textbox(label="Status", lines=2)
810
+ rst_btn = gr.Button("πŸ’₯ Reset ALL", variant="stop")
811
+ rst_st = gr.Textbox(label="Reset Status", lines=2)
812
+
813
+ ref_btn.click(update_library_preview, [], [lib_txt, lib_roi])
814
+ del_btn.click(delete_class_ui, [del_cls], [del_st, lib_txt, lib_roi])
815
+ rst_btn.click(reset_all_ui, [], [rst_st, lib_txt, lib_roi])
816
+ demo.load(update_library_preview, [], [lib_txt, lib_roi])
817
+
818
+ gr.Markdown("""<div class="footer">
819
+ Engine Part CV System v5 β€’ PCA + Anomaly Scoring + Centroid Cosine
820
+ </div>""")
 
 
 
 
 
 
 
 
821
 
 
 
 
 
 
 
822
 
823
  if __name__ == "__main__":
824
+ demo.launch(
825
+ share = False,
826
+ show_error = True,
827
+ theme = gr.themes.Soft(), # ← Gradio 6.0 fix: moved from Blocks()
828
+ css = custom_css,
829
+ )