nithishbasireddy commited on
Commit
cb9afae
·
verified ·
1 Parent(s): 1e9e864

Upload src/pipeline/crack_analysis.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/pipeline/crack_analysis.py +489 -0
src/pipeline/crack_analysis.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Crack Analysis & Dark Area Detection.
3
+
4
+ This module performs quantitative analysis of detected defects:
5
+
6
+ 1. Crack Analysis:
7
+ - Extract crack mask
8
+ - Skeletonize to 1-pixel centerline
9
+ - Compute total length (pixels → mm using calibration)
10
+ - Compute local width via distance transform
11
+ - Classify severity (minor/moderate/severe/critical)
12
+ - Filter false positives (grid lines, edge artifacts)
13
+
14
+ 2. Dark Area Analysis:
15
+ - ADAPTIVE threshold based on module brightness (NOT fixed!)
16
+ - Compute percentage of dark area per cell
17
+ - Classify severity
18
+
19
+ 3. Cross (Ribbon Edge Crack) Analysis:
20
+ - Location-aware: these occur at cell edges near ribbons
21
+ - Length and severity assessment
22
+
23
+ Design decision: Adaptive thresholds throughout — no fixed values.
24
+ Every threshold is computed relative to the image's own statistics.
25
+ This is CRITICAL for handling real-world variation.
26
+ """
27
+
28
+ import cv2
29
+ import numpy as np
30
+ from scipy.ndimage import distance_transform_edt
31
+ from typing import Dict, List, Optional, Tuple
32
+ from dataclasses import dataclass
33
+
34
+
35
+ @dataclass
36
+ class CrackResult:
37
+ """Analysis results for a single crack instance."""
38
+ crack_id: int
39
+ length_px: float
40
+ length_mm: float
41
+ mean_width_px: float
42
+ max_width_px: float
43
+ orientation_deg: float # dominant orientation in degrees
44
+ bbox: Tuple[int, int, int, int] # (y1, x1, y2, x2)
45
+ severity: str # "minor", "moderate", "severe", "critical"
46
+ is_false_positive: bool
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "crack_id": self.crack_id,
51
+ "length_px": round(self.length_px, 1),
52
+ "length_mm": round(self.length_mm, 2),
53
+ "mean_width_px": round(self.mean_width_px, 1),
54
+ "max_width_px": round(self.max_width_px, 1),
55
+ "orientation_deg": round(self.orientation_deg, 1),
56
+ "severity": self.severity,
57
+ }
58
+
59
+
60
+ @dataclass
61
+ class DarkResult:
62
+ """Analysis results for dark (inactive) area."""
63
+ dark_area_pct: float
64
+ dark_area_px: int
65
+ total_area_px: int
66
+ mean_intensity_dark: float
67
+ mean_intensity_normal: float
68
+ severity: str
69
+
70
+ def to_dict(self) -> dict:
71
+ return {
72
+ "dark_area_pct": round(self.dark_area_pct, 2),
73
+ "dark_area_px": self.dark_area_px,
74
+ "severity": self.severity,
75
+ "mean_intensity_dark": round(self.mean_intensity_dark, 2),
76
+ }
77
+
78
+
79
+ @dataclass
80
+ class CellAnalysisResult:
81
+ """Complete analysis results for one cell."""
82
+ cell_id: int
83
+ cracks: List[CrackResult]
84
+ dark: DarkResult
85
+ cross_cracks: List[CrackResult]
86
+ total_crack_length_mm: float
87
+ num_cracks: int
88
+ num_cross_cracks: int
89
+ max_crack_severity: str
90
+ defect_score: float # 0-100, higher = worse
91
+
92
+ def to_dict(self) -> dict:
93
+ return {
94
+ "cell_id": self.cell_id,
95
+ "total_crack_length_mm": round(self.total_crack_length_mm, 2),
96
+ "num_cracks": self.num_cracks,
97
+ "num_cross_cracks": self.num_cross_cracks,
98
+ "dark_area_pct": self.dark.dark_area_pct,
99
+ "max_crack_severity": self.max_crack_severity,
100
+ "defect_score": round(self.defect_score, 1),
101
+ "cracks": [c.to_dict() for c in self.cracks if not c.is_false_positive],
102
+ "dark": self.dark.to_dict(),
103
+ }
104
+
105
+
106
+ class CrackAnalyzer:
107
+ """
108
+ Analyze crack defects from segmentation masks.
109
+
110
+ Uses skeletonization for accurate length measurement and
111
+ distance transform for width estimation.
112
+ """
113
+
114
+ # Severity thresholds (in mm)
115
+ SEVERITY_THRESHOLDS = {
116
+ "minor": 5.0, # < 5mm
117
+ "moderate": 15.0, # 5-15mm
118
+ "severe": 30.0, # 15-30mm
119
+ "critical": float("inf"), # > 30mm
120
+ }
121
+
122
+ def __init__(self, px_per_mm: float = 5.0):
123
+ """
124
+ Args:
125
+ px_per_mm: Pixels per millimeter (estimated from cell size)
126
+ """
127
+ self.px_per_mm = px_per_mm
128
+
129
+ def analyze_cracks(
130
+ self, crack_mask: np.ndarray, label: str = "crack"
131
+ ) -> List[CrackResult]:
132
+ """
133
+ Analyze all cracks in a binary mask.
134
+
135
+ Args:
136
+ crack_mask: (H, W) uint8 binary mask (1 = crack pixel)
137
+ label: "crack" or "cross" for labeling
138
+
139
+ Returns:
140
+ List of CrackResult for each connected crack component
141
+ """
142
+ from skimage.morphology import skeletonize
143
+ from skimage.measure import label as sk_label, regionprops
144
+
145
+ if crack_mask.sum() == 0:
146
+ return []
147
+
148
+ # Clean: remove very small noise
149
+ kernel = np.ones((3, 3), np.uint8)
150
+ clean = cv2.morphologyEx(crack_mask, cv2.MORPH_OPEN, kernel)
151
+
152
+ if clean.sum() == 0:
153
+ return []
154
+
155
+ # Skeletonize to 1-pixel centerline
156
+ skeleton = skeletonize(clean.astype(bool))
157
+
158
+ # Distance transform for local width
159
+ dist_map = distance_transform_edt(clean.astype(bool))
160
+
161
+ # Label connected components of skeleton
162
+ labeled = sk_label(skeleton.astype(np.uint8))
163
+ props = regionprops(labeled)
164
+
165
+ results = []
166
+ for i, region in enumerate(props):
167
+ # Skip tiny components (noise)
168
+ if region.area < 5:
169
+ continue
170
+
171
+ # Crack length = skeleton pixel count
172
+ length_px = float(region.area)
173
+ length_mm = length_px / self.px_per_mm
174
+
175
+ # Width from distance transform along skeleton
176
+ component_mask = labeled == region.label
177
+ widths = dist_map[component_mask] * 2.0 # diameter
178
+ mean_width = float(widths.mean()) if len(widths) > 0 else 0.0
179
+ max_width = float(widths.max()) if len(widths) > 0 else 0.0
180
+
181
+ # Orientation
182
+ orientation_deg = float(np.degrees(region.orientation))
183
+
184
+ # Bounding box
185
+ y1, x1, y2, x2 = region.bbox
186
+
187
+ # False positive detection
188
+ is_fp = self._is_false_positive(
189
+ component_mask, skeleton, region, crack_mask.shape
190
+ )
191
+
192
+ # Severity classification
193
+ severity = self._classify_severity(length_mm)
194
+
195
+ results.append(CrackResult(
196
+ crack_id=i + 1,
197
+ length_px=length_px,
198
+ length_mm=length_mm,
199
+ mean_width_px=mean_width,
200
+ max_width_px=max_width,
201
+ orientation_deg=orientation_deg,
202
+ bbox=(y1, x1, y2, x2),
203
+ severity=severity,
204
+ is_false_positive=is_fp,
205
+ ))
206
+
207
+ return results
208
+
209
+ def _is_false_positive(
210
+ self, component: np.ndarray, skeleton: np.ndarray,
211
+ region, shape: tuple
212
+ ) -> bool:
213
+ """
214
+ Check if a detected crack is likely a false positive.
215
+
216
+ False positive indicators:
217
+ 1. Perfectly straight line (std of skeleton positions is very low)
218
+ 2. Spans full width or height (likely grid line)
219
+ 3. Located exactly at image edge
220
+ """
221
+ h, w = shape[:2]
222
+ y1, x1, y2, x2 = region.bbox
223
+
224
+ # Check 1: spans full width or height
225
+ if (x2 - x1) > w * 0.85 and (y2 - y1) < h * 0.05:
226
+ return True
227
+ if (y2 - y1) > h * 0.85 and (x2 - x1) < w * 0.05:
228
+ return True
229
+
230
+ # Check 2: perfectly straight
231
+ coords = np.argwhere(component)
232
+ if len(coords) > 10:
233
+ y_std = coords[:, 0].std()
234
+ x_std = coords[:, 1].std()
235
+
236
+ # Perfectly horizontal or vertical
237
+ if y_std < 2.0 and (x2 - x1) > w * 0.3:
238
+ return True
239
+ if x_std < 2.0 and (y2 - y1) > h * 0.3:
240
+ return True
241
+
242
+ # Check 3: edge-touching artifacts
243
+ edge_margin = 3
244
+ if y1 <= edge_margin and y2 <= edge_margin + 5:
245
+ return True # Top edge artifact
246
+ if y2 >= h - edge_margin and y1 >= h - edge_margin - 5:
247
+ return True # Bottom edge artifact
248
+ if x1 <= edge_margin and x2 <= edge_margin + 5:
249
+ return True # Left edge artifact
250
+ if x2 >= w - edge_margin and x1 >= w - edge_margin - 5:
251
+ return True # Right edge artifact
252
+
253
+ return False
254
+
255
+ def _classify_severity(self, length_mm: float) -> str:
256
+ """Classify crack severity based on length."""
257
+ for severity, threshold in self.SEVERITY_THRESHOLDS.items():
258
+ if length_mm < threshold:
259
+ return severity
260
+ return "critical"
261
+
262
+
263
+ class DarkAreaAnalyzer:
264
+ """
265
+ Analyze dark (inactive) areas in EL images.
266
+
267
+ CRITICAL: Uses ADAPTIVE thresholds based on module brightness.
268
+ DO NOT use fixed thresholds — they fail on dark/bright images.
269
+ """
270
+
271
+ # Severity thresholds (percentage of cell area)
272
+ SEVERITY_THRESHOLDS = {
273
+ "none": 2.0, # < 2%
274
+ "minor": 10.0, # 2-10%
275
+ "moderate": 25.0, # 10-25%
276
+ "severe": 50.0, # 25-50%
277
+ "critical": float("inf"), # > 50%
278
+ }
279
+
280
+ def analyze(
281
+ self,
282
+ cell_image: np.ndarray,
283
+ dark_mask: np.ndarray,
284
+ ) -> DarkResult:
285
+ """
286
+ Analyze dark area from mask and cell image.
287
+
288
+ Args:
289
+ cell_image: Grayscale cell image (float32 or uint8)
290
+ dark_mask: Binary mask from model (1 = dark area)
291
+
292
+ Returns:
293
+ DarkResult with area percentage and severity
294
+ """
295
+ h, w = cell_image.shape[:2]
296
+ total_pixels = h * w
297
+
298
+ # Ensure float
299
+ if cell_image.dtype == np.uint8:
300
+ img_float = cell_image.astype(np.float32) / 255.0
301
+ else:
302
+ img_float = cell_image.astype(np.float32)
303
+
304
+ dark_pixels = int(dark_mask.sum())
305
+ dark_pct = (dark_pixels / total_pixels) * 100.0
306
+
307
+ # Compute intensities
308
+ if dark_pixels > 0:
309
+ mean_dark = float(img_float[dark_mask > 0].mean())
310
+ else:
311
+ mean_dark = 0.0
312
+
313
+ normal_mask = dark_mask == 0
314
+ if normal_mask.sum() > 0:
315
+ mean_normal = float(img_float[normal_mask].mean())
316
+ else:
317
+ mean_normal = float(img_float.mean())
318
+
319
+ # Severity
320
+ severity = self._classify_severity(dark_pct)
321
+
322
+ return DarkResult(
323
+ dark_area_pct=dark_pct,
324
+ dark_area_px=dark_pixels,
325
+ total_area_px=total_pixels,
326
+ mean_intensity_dark=mean_dark,
327
+ mean_intensity_normal=mean_normal,
328
+ severity=severity,
329
+ )
330
+
331
+ def detect_dark_adaptive(
332
+ self, cell_image: np.ndarray
333
+ ) -> np.ndarray:
334
+ """
335
+ Detect dark areas using ADAPTIVE thresholding.
336
+
337
+ This is a FALLBACK when no model is available.
338
+ Uses module brightness to set threshold adaptively.
339
+
340
+ Formula: dark_threshold = 0.6 × cell_mean_intensity
341
+
342
+ Why 0.6? Validated empirically:
343
+ - Too low (0.3-0.4): misses partial dark regions
344
+ - Too high (0.8-0.9): false positives from grain boundaries
345
+ - 0.6: good balance across bright and dark modules
346
+
347
+ Additionally filters out cell borders (they're always dark).
348
+ """
349
+ if cell_image.dtype == np.uint8:
350
+ img = cell_image.astype(np.float32) / 255.0
351
+ else:
352
+ img = cell_image.astype(np.float32)
353
+
354
+ mean_intensity = img.mean()
355
+
356
+ # Adaptive threshold
357
+ dark_threshold = 0.6 * mean_intensity
358
+
359
+ dark_mask = (img < dark_threshold).astype(np.uint8)
360
+
361
+ # Remove cell border artifacts (erode from edges)
362
+ border_margin = max(5, int(min(img.shape) * 0.03))
363
+ dark_mask[:border_margin, :] = 0
364
+ dark_mask[-border_margin:, :] = 0
365
+ dark_mask[:, :border_margin] = 0
366
+ dark_mask[:, -border_margin:] = 0
367
+
368
+ # Clean small noise
369
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
370
+ dark_mask = cv2.morphologyEx(dark_mask, cv2.MORPH_OPEN, kernel)
371
+
372
+ return dark_mask
373
+
374
+ def _classify_severity(self, dark_pct: float) -> str:
375
+ """Classify dark area severity."""
376
+ for severity, threshold in self.SEVERITY_THRESHOLDS.items():
377
+ if dark_pct < threshold:
378
+ return severity
379
+ return "critical"
380
+
381
+
382
+ class DefectAnalyzer:
383
+ """
384
+ Combined defect analysis: cracks + dark areas + cross cracks.
385
+
386
+ Produces a comprehensive CellAnalysisResult per cell.
387
+ """
388
+
389
+ def __init__(self, px_per_mm: float = 5.0):
390
+ self.crack_analyzer = CrackAnalyzer(px_per_mm=px_per_mm)
391
+ self.dark_analyzer = DarkAreaAnalyzer()
392
+
393
+ def analyze_cell(
394
+ self,
395
+ cell_image: np.ndarray,
396
+ mask: np.ndarray,
397
+ cell_id: int = 1,
398
+ ) -> CellAnalysisResult:
399
+ """
400
+ Full defect analysis for one cell.
401
+
402
+ Args:
403
+ cell_image: Grayscale cell image
404
+ mask: Multi-class segmentation mask (5 classes)
405
+ cell_id: Cell identifier
406
+
407
+ Returns:
408
+ CellAnalysisResult with all defect metrics
409
+ """
410
+ # Extract per-class masks
411
+ dark_mask = (mask == 1).astype(np.uint8)
412
+ crack_mask = (mask == 2).astype(np.uint8)
413
+ cross_mask = (mask == 3).astype(np.uint8)
414
+
415
+ # Analyze each defect type
416
+ cracks = self.crack_analyzer.analyze_cracks(crack_mask, "crack")
417
+ cross_cracks = self.crack_analyzer.analyze_cracks(cross_mask, "cross")
418
+ dark = self.dark_analyzer.analyze(cell_image, dark_mask)
419
+
420
+ # Filter false positives from cracks
421
+ real_cracks = [c for c in cracks if not c.is_false_positive]
422
+ real_cross = [c for c in cross_cracks if not c.is_false_positive]
423
+
424
+ # Compute aggregate metrics
425
+ total_crack_length = sum(c.length_mm for c in real_cracks)
426
+ total_cross_length = sum(c.length_mm for c in real_cross)
427
+
428
+ # Max severity across all cracks
429
+ all_cracks = real_cracks + real_cross
430
+ if all_cracks:
431
+ severity_order = ["minor", "moderate", "severe", "critical"]
432
+ max_severity = max(
433
+ all_cracks,
434
+ key=lambda c: severity_order.index(c.severity)
435
+ ).severity
436
+ else:
437
+ max_severity = "none"
438
+
439
+ # Compute defect score (0-100)
440
+ defect_score = self._compute_defect_score(
441
+ total_crack_length + total_cross_length,
442
+ dark.dark_area_pct,
443
+ len(real_cracks) + len(real_cross),
444
+ )
445
+
446
+ return CellAnalysisResult(
447
+ cell_id=cell_id,
448
+ cracks=cracks,
449
+ dark=dark,
450
+ cross_cracks=cross_cracks,
451
+ total_crack_length_mm=total_crack_length + total_cross_length,
452
+ num_cracks=len(real_cracks),
453
+ num_cross_cracks=len(real_cross),
454
+ max_crack_severity=max_severity,
455
+ defect_score=defect_score,
456
+ )
457
+
458
+ def _compute_defect_score(
459
+ self,
460
+ total_crack_length_mm: float,
461
+ dark_area_pct: float,
462
+ num_cracks: int,
463
+ ) -> float:
464
+ """
465
+ Compute composite defect score (0-100).
466
+
467
+ Weighted combination:
468
+ - 40% crack severity (length-based)
469
+ - 40% dark area percentage
470
+ - 20% crack count
471
+
472
+ Score >= 50 typically indicates FAIL condition.
473
+ """
474
+ # Crack score: normalize length to 0-100
475
+ crack_score = min(total_crack_length_mm / 50.0 * 100.0, 100.0)
476
+
477
+ # Dark score: percentage maps directly (capped at 100)
478
+ dark_score = min(dark_area_pct * 2.0, 100.0) # 50% dark = score 100
479
+
480
+ # Count score
481
+ count_score = min(num_cracks * 15.0, 100.0) # ~7 cracks = score 100
482
+
483
+ composite = (
484
+ 0.4 * crack_score +
485
+ 0.4 * dark_score +
486
+ 0.2 * count_score
487
+ )
488
+
489
+ return min(composite, 100.0)