add the pca compress
Browse files
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
|
| 19 |
-
CLUSTER_VERSION
|
| 20 |
-
TEXTURE_WEIGHT
|
| 21 |
-
MIN_SAMPLES_WARN
|
| 22 |
-
MIN_MATCH_SAMPLES
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
-
#
|
| 27 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
|
| 29 |
class CLAHEProcessor:
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
# Add labels
|
| 90 |
-
def _label(img, txt):
|
| 91 |
out = img.copy()
|
| 92 |
-
cv2.putText(out, txt, (10,
|
| 93 |
-
0.9, (255,
|
| 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
|
| 107 |
self.backbone.eval()
|
| 108 |
self.transform = transforms.Compose([
|
| 109 |
-
transforms.Resize((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
|
| 117 |
-
|
| 118 |
-
g
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 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 |
-
|
| 137 |
pf = p.astype(np.float64)
|
| 138 |
-
|
| 139 |
-
hp,
|
| 140 |
-
hp
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
gxp
|
| 144 |
-
gyp
|
| 145 |
-
|
| 146 |
|
| 147 |
for theta in [0, np.pi/4, np.pi/2, 3*np.pi/4]:
|
| 148 |
-
for sigma in [3.
|
| 149 |
-
k = cv2.getGaborKernel((21,21),
|
| 150 |
f = cv2.filter2D(g, cv2.CV_64F, k)
|
| 151 |
-
|
| 152 |
|
| 153 |
-
return np.array(
|
| 154 |
|
| 155 |
-
def
|
|
|
|
| 156 |
if isinstance(rgb, Image.Image):
|
| 157 |
rgb = np.array(rgb.convert("RGB"))
|
| 158 |
-
|
| 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.
|
| 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 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 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 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 199 |
-
|
|
|
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
|
| 206 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -210,29 +251,54 @@ class FeatureExtractor:
|
|
| 210 |
class EnginePartDetector:
|
| 211 |
|
| 212 |
def __init__(self):
|
| 213 |
-
self.
|
| 214 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
centroid = np.mean(vecs, axis=0)
|
|
|
|
|
|
|
|
|
|
| 225 |
if len(vecs) > 1:
|
| 226 |
-
dists = [
|
| 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 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 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
|
| 274 |
-
img_rgb = image_source
|
| 275 |
img_h, img_w = img_rgb.shape[:2]
|
| 276 |
-
gray
|
| 277 |
-
cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY),
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
circles = cv2.HoughCircles(
|
| 281 |
gray, cv2.HOUGH_GRADIENT, dp=1.2,
|
| 282 |
-
minDist
|
| 283 |
-
|
| 284 |
-
|
| 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 =
|
| 292 |
y_med = np.median(ys)
|
| 293 |
-
top_row = sorted([c for c in circles if c[1]
|
| 294 |
-
bot_row = sorted([c for c in circles if c[1]
|
| 295 |
|
| 296 |
-
if len(top_row)
|
| 297 |
return img_rgb, img_rgb, "β οΈ Insufficient hole rows."
|
| 298 |
|
| 299 |
-
y_top
|
| 300 |
-
y_bot
|
| 301 |
-
xs
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
y_end = min(img_h, max(y_top, y_bot) + 20)
|
| 306 |
|
| 307 |
vis = img_rgb.copy()
|
| 308 |
-
cv2.line(vis,
|
| 309 |
-
cv2.line(vis,
|
| 310 |
-
for (x,
|
| 311 |
-
cv2.circle(vis,
|
| 312 |
-
cv2.circle(vis,
|
| 313 |
|
| 314 |
-
crop = img_rgb[
|
| 315 |
if crop.size == 0:
|
| 316 |
return vis, img_rgb, "β οΈ ROI crop failed."
|
| 317 |
|
| 318 |
-
stats = (f"β
ROI
|
| 319 |
f"{len(top_row)} top, {len(bot_row)} bottom | "
|
| 320 |
-
f"
|
| 321 |
return vis, crop, stats
|
| 322 |
|
| 323 |
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 324 |
|
| 325 |
@staticmethod
|
| 326 |
-
def _cosine(a
|
| 327 |
-
na,
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
self.
|
| 338 |
-
|
| 339 |
-
if
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 352 |
if "β" in log or "β οΈ" in log:
|
| 353 |
return log, None
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
self._persist_data()
|
| 358 |
|
| 359 |
n = len(self.classes[class_name])
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
""
|
| 373 |
-
|
| 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
|
| 394 |
-
|
| 395 |
-
fail_count = 0
|
| 396 |
-
log_lines = []
|
| 397 |
-
last_roi = None
|
| 398 |
|
| 399 |
for idx, fp in enumerate(file_paths):
|
| 400 |
-
|
| 401 |
-
path = fp if isinstance(fp, str) else fp.get("name", str(fp))
|
| 402 |
fname = os.path.basename(path)
|
| 403 |
-
|
| 404 |
try:
|
| 405 |
-
|
| 406 |
-
image = np.array(img_pil)
|
| 407 |
except Exception as e:
|
| 408 |
log_lines.append(f"β [{idx+1}/{total}] {fname} β load error: {e}")
|
| 409 |
-
|
| 410 |
-
continue
|
| 411 |
|
| 412 |
-
vis, roi,
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
fail_count += 1
|
| 417 |
-
continue
|
| 418 |
|
| 419 |
try:
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
|
|
|
| 425 |
except Exception as e:
|
| 426 |
-
log_lines.append(f"β [{idx+1}/{total}] {fname} β
|
| 427 |
-
|
| 428 |
|
| 429 |
-
if progress_cb:
|
| 430 |
-
progress_cb(idx + 1, total)
|
| 431 |
|
| 432 |
-
|
| 433 |
-
|
|
|
|
| 434 |
self._persist_data()
|
| 435 |
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
summary = (
|
| 442 |
-
f"### Bulk Upload
|
| 443 |
-
f"- **Class**: `{class_name}`
|
| 444 |
-
f"
|
| 445 |
-
f"-
|
| 446 |
-
f"-
|
| 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
|
| 457 |
if not self.classes:
|
| 458 |
-
return ("β οΈ No
|
| 459 |
-
"Add samples first.", None, None, None, None)
|
| 460 |
|
| 461 |
-
vis, roi, log = self.
|
| 462 |
if "β" in log or "β οΈ" in log:
|
| 463 |
-
return f"β
|
| 464 |
|
| 465 |
-
|
| 466 |
|
| 467 |
-
#
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 474 |
-
None, vis, None, None)
|
| 475 |
|
| 476 |
-
#
|
| 477 |
class_scores = []
|
| 478 |
for name, centroid in eligible.items():
|
| 479 |
-
|
| 480 |
-
spread
|
| 481 |
-
|
| 482 |
-
class_scores.append((name,
|
| 483 |
-
|
| 484 |
-
class_scores.sort(key=lambda x:
|
| 485 |
-
best_name, best_adj,
|
| 486 |
-
second_adj
|
| 487 |
-
|
| 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 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
else:
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
|
| 519 |
lines = [
|
| 520 |
-
f"
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
f"
|
| 524 |
-
f"**π― Status** : {status}",
|
| 525 |
"",
|
|
|
|
| 526 |
]
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
lines.append("")
|
| 530 |
|
| 531 |
lines += [
|
|
|
|
|
|
|
|
|
|
| 532 |
"### Pipeline",
|
| 533 |
-
"1. ROI localisation
|
| 534 |
-
"
|
| 535 |
-
"
|
| 536 |
-
"4. Centroid cosine + spread penalty + balance weight",
|
| 537 |
-
"---", log, "",
|
| 538 |
-
"**Class probabilities:**",
|
| 539 |
]
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
lines.append(f"{marker}`{name}`: {prob:.1%} (cosine: {raw:.4f})")
|
| 543 |
|
| 544 |
-
label_dict = {n: float(p) for n,
|
| 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,
|
| 549 |
|
| 550 |
-
return "\n".join(lines), label_dict, vis,
|
| 551 |
|
| 552 |
# ββ Utility βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 553 |
|
| 554 |
-
def get_template_roi(self,
|
| 555 |
-
return self.class_rois.get(
|
| 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)
|
| 567 |
-
|
| 568 |
-
|
| 569 |
return "\n".join(lines)
|
| 570 |
|
| 571 |
-
def delete_class(self,
|
| 572 |
-
if
|
| 573 |
-
del self.classes[
|
| 574 |
-
for d in [self.centroids, self.class_spread, self.class_rois]:
|
| 575 |
-
d.pop(
|
|
|
|
| 576 |
self._persist_data()
|
| 577 |
return True
|
| 578 |
return False
|
| 579 |
|
| 580 |
def reset_all(self) -> str:
|
| 581 |
-
self.classes
|
| 582 |
-
self.
|
| 583 |
-
|
| 584 |
-
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
| 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"β
|
| 626 |
txt, roi = update_library_preview()
|
| 627 |
return msg, txt, roi
|
| 628 |
|
| 629 |
def reset_all_ui():
|
| 630 |
-
|
| 631 |
-
return msg, "No classes trained yet.", None
|
| 632 |
|
| 633 |
|
| 634 |
custom_css = """
|
| 635 |
-
.header
|
| 636 |
-
.footer
|
| 637 |
"""
|
| 638 |
|
| 639 |
-
with gr.Blocks(title="Engine Part CV System
|
| 640 |
-
css=custom_css) as demo:
|
| 641 |
|
| 642 |
gr.Markdown("""
|
| 643 |
<div class="header">
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 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 |
-
# ββ
|
| 655 |
with gr.Tab("π Inspect Part"):
|
| 656 |
with gr.Row():
|
| 657 |
-
with gr.Column(
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
match_label = gr.Label(label="Class Probabilities", num_top_classes=5)
|
| 667 |
with gr.Row():
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 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 |
-
|
| 680 |
-
|
| 681 |
-
|
| 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 |
-
# ββ
|
| 702 |
-
with gr.Tab("
|
| 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(
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
clahe_btn = gr.Button("π¨ Preview Enhancement", variant="secondary")
|
| 748 |
with gr.Column(scale=2):
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
clahe_btn.click(
|
| 752 |
-
fn=clahe_preview,
|
| 753 |
-
inputs=[clahe_input],
|
| 754 |
-
outputs=[clahe_output],
|
| 755 |
-
)
|
| 756 |
|
| 757 |
-
# ββ
|
| 758 |
with gr.Tab("π Class Library"):
|
| 759 |
with gr.Row():
|
| 760 |
-
with gr.Column(
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
library_roi_view = gr.Image(label="Last Reference ROI",
|
| 766 |
-
interactive=False)
|
| 767 |
gr.Markdown("### β οΈ Danger Zone")
|
| 768 |
with gr.Row():
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|