nithishbasireddy commited on
Commit
396eb7f
Β·
verified Β·
1 Parent(s): 1bac9b8

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +618 -0
app.py ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EL Defect Detection β€” Streamlit App (Production)
3
+
4
+ Runs with trained U-Net++ model. No mock inference.
5
+ Fixed grid detection: single cells stay single, full modules are properly segmented.
6
+
7
+ Usage:
8
+ streamlit run app.py
9
+ """
10
+
11
+ import sys
12
+ import os
13
+ import json
14
+ import numpy as np
15
+ import cv2
16
+ import torch
17
+ import torch.nn.functional as F
18
+ from PIL import Image
19
+ from io import BytesIO
20
+ from pathlib import Path
21
+ from dataclasses import dataclass
22
+ from typing import List, Tuple, Optional, Dict
23
+
24
+ import streamlit as st
25
+ import segmentation_models_pytorch as smp
26
+ from scipy.signal import find_peaks
27
+ from scipy.ndimage import distance_transform_edt
28
+
29
+ try:
30
+ from skimage.morphology import skeletonize
31
+ from skimage.measure import label as sk_label, regionprops
32
+ SKIMAGE_OK = True
33
+ except ImportError:
34
+ SKIMAGE_OK = False
35
+
36
+
37
+ # ═══════════════════════════════════════════════════════════════
38
+ # LABEL REMAP (must match training)
39
+ # ═══════════════════════════════════════════════════════════════
40
+
41
+ LABEL_REMAP = np.zeros(30, dtype=np.uint8)
42
+ LABEL_REMAP[9] = 1 # busbars
43
+ LABEL_REMAP[10] = 2 # crack_rbn_edge
44
+ LABEL_REMAP[14] = 2 # crack
45
+ LABEL_REMAP[11] = 3 # inactive
46
+ LABEL_REMAP[17] = 3 # dead_cell
47
+ LABEL_REMAP[20] = 3 # edge_dark
48
+ for lbl in [12, 13, 15, 16, 18, 19, 25, 26, 27, 28]:
49
+ LABEL_REMAP[lbl] = 4 # other_defect
50
+
51
+ CLASS_NAMES = ["background", "busbar", "crack", "dark", "other_defect"]
52
+ CLASS_COLORS_RGB = {
53
+ "background": (0, 0, 0),
54
+ "busbar": (0, 200, 0), # Green
55
+ "crack": (0, 100, 255), # Blue
56
+ "dark": (255, 50, 50), # Red
57
+ "other_defect": (255, 200, 0), # Yellow
58
+ }
59
+
60
+
61
+ # ═══════════════════════════════════════════════════════════════
62
+ # MODEL LOADING
63
+ # ═══════════════════════════════════════════════════════════════
64
+
65
+ @st.cache_resource
66
+ def load_model(model_path: str):
67
+ """Load trained model. Returns (model, device, metadata)."""
68
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
69
+
70
+ if not os.path.exists(model_path):
71
+ return None, device, {}
72
+
73
+ checkpoint = torch.load(model_path, map_location=device, weights_only=False)
74
+
75
+ arch = checkpoint.get("architecture", "UnetPlusPlus")
76
+ encoder = checkpoint.get("encoder", "efficientnet-b4")
77
+ num_classes = checkpoint.get("num_classes", 5)
78
+
79
+ ModelClass = getattr(smp, arch)
80
+ model = ModelClass(
81
+ encoder_name=encoder,
82
+ encoder_weights=None,
83
+ in_channels=1,
84
+ classes=num_classes,
85
+ decoder_attention_type="scse",
86
+ )
87
+
88
+ state_dict = checkpoint.get("model_state_dict", checkpoint)
89
+ model.load_state_dict(state_dict, strict=False)
90
+ model.to(device)
91
+ model.eval()
92
+
93
+ meta = {
94
+ "architecture": arch,
95
+ "encoder": encoder,
96
+ "val_dice": checkpoint.get("val_dice", 0),
97
+ "val_iou": checkpoint.get("val_iou", 0),
98
+ "epoch": checkpoint.get("epoch", 0),
99
+ }
100
+
101
+ return model, device, meta
102
+
103
+
104
+ # ═══════════════════════════════════════════════════════════════
105
+ # PREPROCESSING
106
+ # ═══════════════════════════════════════════════════════════════
107
+
108
+ def preprocess_image(img_np: np.ndarray, target_size: int = 512) -> Tuple[np.ndarray, np.ndarray]:
109
+ """
110
+ Preprocess EL image for model input.
111
+ Returns: (model_input [1,1,H,W] float32, display_gray [H,W] uint8)
112
+ """
113
+ # Convert to grayscale
114
+ if img_np.ndim == 3:
115
+ if img_np.shape[2] == 4:
116
+ gray = cv2.cvtColor(img_np, cv2.COLOR_RGBA2GRAY)
117
+ else:
118
+ gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
119
+ else:
120
+ gray = img_np.copy()
121
+
122
+ if gray.dtype != np.uint8:
123
+ gray = (np.clip(gray, 0, 255)).astype(np.uint8)
124
+
125
+ orig_gray = gray.copy()
126
+
127
+ # CLAHE
128
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
129
+ enhanced = clahe.apply(gray)
130
+
131
+ # Resize to model input
132
+ resized = cv2.resize(enhanced, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
133
+
134
+ # Normalize: [0, 255] β†’ [0, 1]
135
+ normalized = resized.astype(np.float32) / 255.0
136
+
137
+ # To tensor shape: (1, 1, H, W)
138
+ tensor = normalized[np.newaxis, np.newaxis, ...]
139
+
140
+ return tensor, orig_gray
141
+
142
+
143
+ # ═══════════════════════════════════════════════════════════════
144
+ # INFERENCE
145
+ # ═══════════════════════════════════════════════════════════════
146
+
147
+ def predict(model, device, tensor_input: np.ndarray) -> np.ndarray:
148
+ """Run model inference. Returns (H, W) class mask."""
149
+ x = torch.from_numpy(tensor_input).float().to(device)
150
+
151
+ with torch.no_grad():
152
+ with torch.amp.autocast(device_type=device.type, enabled=(device.type == "cuda")):
153
+ logits = model(x)
154
+
155
+ mask = torch.argmax(logits, dim=1).squeeze(0).cpu().numpy().astype(np.uint8)
156
+ return mask
157
+
158
+
159
+ # ═══════════════════════════════════════════════════════════════
160
+ # GRID DETECTION β€” FIXED VERSION
161
+ # ═══════════════════════════════════════════════════════════════
162
+
163
+ @dataclass
164
+ class CellInfo:
165
+ cell_id: int
166
+ row: int
167
+ col: int
168
+ bbox: Tuple[int, int, int, int] # y1, x1, y2, x2
169
+ image: Optional[np.ndarray] = None
170
+
171
+
172
+ def detect_grid(gray: np.ndarray, min_cells: int = 4) -> List[CellInfo]:
173
+ """
174
+ Detect cell grid in module image.
175
+
176
+ FIXED LOGIC:
177
+ - Only segment if we find a clear periodic grid with >= min_cells
178
+ - Single cells (no grid) β†’ return as one cell
179
+ - Requires BOTH row and column grid lines to segment
180
+ - Uses stricter periodicity validation
181
+ """
182
+ h, w = gray.shape
183
+
184
+ # Apply CLAHE for better grid contrast
185
+ clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
186
+ enhanced = clahe.apply(gray if gray.dtype == np.uint8 else (gray * 255).astype(np.uint8))
187
+
188
+ # Compute projections (inverted β€” dark gaps become peaks)
189
+ inv = 255 - enhanced
190
+ row_proj = inv.mean(axis=1).astype(np.float64) # horizontal gaps
191
+ col_proj = inv.mean(axis=0).astype(np.float64) # vertical gaps
192
+
193
+ # Smooth
194
+ from scipy.signal import medfilt
195
+ ks = max(3, h // 100) | 1 # ensure odd
196
+ row_proj = medfilt(row_proj, kernel_size=ks)
197
+ ks = max(3, w // 100) | 1
198
+ col_proj = medfilt(col_proj, kernel_size=ks)
199
+
200
+ # Find peaks β€” STRICT parameters
201
+ row_range = row_proj.max() - row_proj.min()
202
+ col_range = col_proj.max() - col_proj.min()
203
+
204
+ # Require prominent peaks (at least 20% of range)
205
+ row_peaks, _ = find_peaks(row_proj, prominence=row_range * 0.2, distance=h // 20)
206
+ col_peaks, _ = find_peaks(col_proj, prominence=col_range * 0.2, distance=w // 20)
207
+
208
+ # Validate periodicity β€” peaks must be roughly evenly spaced
209
+ row_peaks = _validate_periodic(row_peaks, min_count=2)
210
+ col_peaks = _validate_periodic(col_peaks, min_count=1)
211
+
212
+ # Need enough grid lines to form min_cells
213
+ n_row_cells = len(row_peaks) + 1
214
+ n_col_cells = len(col_peaks) + 1
215
+ total_cells = n_row_cells * n_col_cells
216
+
217
+ if total_cells < min_cells:
218
+ # Not enough grid β†’ treat as single cell
219
+ return [CellInfo(cell_id=1, row=0, col=0, bbox=(0, 0, h, w), image=gray)]
220
+
221
+ # Extract cells
222
+ row_bounds = np.concatenate([[0], row_peaks, [h]])
223
+ col_bounds = np.concatenate([[0], col_peaks, [w]])
224
+
225
+ cells = []
226
+ cell_id = 1
227
+ min_dim = max(20, min(h, w) // 30)
228
+
229
+ for i in range(len(row_bounds) - 1):
230
+ for j in range(len(col_bounds) - 1):
231
+ y1, y2 = int(row_bounds[i]), int(row_bounds[i+1])
232
+ x1, x2 = int(col_bounds[j]), int(col_bounds[j+1])
233
+
234
+ if y2 - y1 < min_dim or x2 - x1 < min_dim:
235
+ continue
236
+
237
+ cell_img = gray[y1:y2, x1:x2]
238
+ if cell_img.mean() < 5: # Skip pure black regions
239
+ continue
240
+
241
+ cells.append(CellInfo(
242
+ cell_id=cell_id, row=i, col=j,
243
+ bbox=(y1, x1, y2, x2), image=cell_img.copy()
244
+ ))
245
+ cell_id += 1
246
+
247
+ if len(cells) == 0:
248
+ return [CellInfo(cell_id=1, row=0, col=0, bbox=(0, 0, h, w), image=gray)]
249
+
250
+ return cells
251
+
252
+
253
+ def _validate_periodic(peaks: np.ndarray, min_count: int = 2) -> np.ndarray:
254
+ """Keep only peaks that form a roughly periodic pattern."""
255
+ if len(peaks) < min_count + 1:
256
+ return np.array([], dtype=int)
257
+
258
+ spacings = np.diff(peaks)
259
+ if len(spacings) == 0:
260
+ return np.array([], dtype=int)
261
+
262
+ median_sp = np.median(spacings)
263
+ if median_sp < 10:
264
+ return np.array([], dtype=int)
265
+
266
+ # Keep peaks where spacing is within 40% of median
267
+ good = [peaks[0]]
268
+ for i in range(len(spacings)):
269
+ if abs(spacings[i] - median_sp) < median_sp * 0.4:
270
+ good.append(peaks[i + 1])
271
+ # If spacing is ~2x median, it's a missing line β€” still valid
272
+ elif abs(spacings[i] - 2 * median_sp) < median_sp * 0.4:
273
+ good.append(peaks[i + 1])
274
+
275
+ if len(good) < min_count + 1:
276
+ return np.array([], dtype=int)
277
+
278
+ return np.array(good)
279
+
280
+
281
+ # ═══════════════════════════════════════════════════════════════
282
+ # DEFECT ANALYSIS
283
+ # ═══════════════════════════════════════════════════════════════
284
+
285
+ def analyze_cell(cell_img: np.ndarray, mask: np.ndarray, px_per_mm: float = 3.3) -> dict:
286
+ """Analyze defects in one cell from its segmentation mask."""
287
+ h, w = mask.shape
288
+ total_px = h * w
289
+
290
+ # Class areas
291
+ busbar_pct = (mask == 1).sum() / total_px * 100
292
+ crack_pct = (mask == 2).sum() / total_px * 100
293
+ dark_pct = (mask == 3).sum() / total_px * 100
294
+ other_pct = (mask == 4).sum() / total_px * 100
295
+
296
+ # Crack length via skeletonization
297
+ crack_length_mm = 0.0
298
+ num_cracks = 0
299
+ if SKIMAGE_OK and (mask == 2).sum() > 5:
300
+ crack_binary = (mask == 2).astype(np.uint8)
301
+ try:
302
+ skeleton = skeletonize(crack_binary.astype(bool))
303
+ crack_length_px = skeleton.sum()
304
+ crack_length_mm = crack_length_px / px_per_mm
305
+
306
+ labeled = sk_label(skeleton.astype(np.uint8))
307
+ num_cracks = labeled.max()
308
+ except Exception:
309
+ pass
310
+
311
+ # Dark severity
312
+ if dark_pct > 50:
313
+ dark_severity = "critical"
314
+ elif dark_pct > 25:
315
+ dark_severity = "severe"
316
+ elif dark_pct > 10:
317
+ dark_severity = "moderate"
318
+ elif dark_pct > 2:
319
+ dark_severity = "minor"
320
+ else:
321
+ dark_severity = "none"
322
+
323
+ # Crack severity
324
+ if crack_length_mm > 30:
325
+ crack_severity = "critical"
326
+ elif crack_length_mm > 15:
327
+ crack_severity = "severe"
328
+ elif crack_length_mm > 5:
329
+ crack_severity = "moderate"
330
+ elif crack_length_mm > 0.5:
331
+ crack_severity = "minor"
332
+ else:
333
+ crack_severity = "none"
334
+
335
+ # Defect score (0-100)
336
+ score = min(100.0,
337
+ 0.35 * min(crack_length_mm / 50 * 100, 100) +
338
+ 0.35 * min(dark_pct * 2, 100) +
339
+ 0.15 * min(num_cracks * 15, 100) +
340
+ 0.15 * min(other_pct * 3, 100)
341
+ )
342
+
343
+ return {
344
+ "busbar_pct": round(busbar_pct, 2),
345
+ "crack_pct": round(crack_pct, 2),
346
+ "dark_pct": round(dark_pct, 2),
347
+ "other_defect_pct": round(other_pct, 2),
348
+ "crack_length_mm": round(crack_length_mm, 2),
349
+ "num_cracks": int(num_cracks),
350
+ "dark_severity": dark_severity,
351
+ "crack_severity": crack_severity,
352
+ "defect_score": round(score, 1),
353
+ }
354
+
355
+
356
+ def module_decision(cell_results: List[dict], thresholds: dict) -> dict:
357
+ """PASS/FAIL decision from per-cell results."""
358
+ if not cell_results:
359
+ return {"decision": "PASS", "score": 0, "reasons": [], "cells": []}
360
+
361
+ reasons = []
362
+ defective = 0
363
+
364
+ for i, r in enumerate(cell_results):
365
+ fails = []
366
+ if r["defect_score"] > thresholds.get("max_score", 50):
367
+ fails.append(f"Cell {i+1}: score {r['defect_score']:.0f}")
368
+ if r["crack_length_mm"] > thresholds.get("max_crack_mm", 30):
369
+ fails.append(f"Cell {i+1}: crack {r['crack_length_mm']:.1f}mm")
370
+ if r["dark_pct"] > thresholds.get("max_dark_pct", 40):
371
+ fails.append(f"Cell {i+1}: dark {r['dark_pct']:.1f}%")
372
+ if fails:
373
+ defective += 1
374
+ reasons.extend(fails)
375
+
376
+ avg_score = np.mean([r["defect_score"] for r in cell_results])
377
+ decision = "FAIL" if reasons else "PASS"
378
+
379
+ return {
380
+ "decision": decision,
381
+ "score": round(avg_score, 1),
382
+ "num_defective": defective,
383
+ "num_cells": len(cell_results),
384
+ "reasons": reasons,
385
+ }
386
+
387
+
388
+ # ═══════════════════════════════════════════════════════════════
389
+ # VISUALIZATION
390
+ # ═══════════════════════════════════════════════════════════════
391
+
392
+ def create_overlay(gray: np.ndarray, mask: np.ndarray, alpha: float = 0.4) -> np.ndarray:
393
+ """Create colored overlay of mask on grayscale image."""
394
+ if gray.ndim == 2:
395
+ vis = cv2.cvtColor(gray if gray.dtype == np.uint8 else (gray * 255).astype(np.uint8),
396
+ cv2.COLOR_GRAY2RGB)
397
+ else:
398
+ vis = gray.copy()
399
+
400
+ h, w = vis.shape[:2]
401
+ if mask.shape[:2] != (h, w):
402
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
403
+
404
+ overlay = vis.copy()
405
+ for idx, name in enumerate(CLASS_NAMES):
406
+ if idx == 0:
407
+ continue
408
+ color = CLASS_COLORS_RGB[name]
409
+ overlay[mask == idx] = color
410
+
411
+ return cv2.addWeighted(vis, 1 - alpha, overlay, alpha, 0)
412
+
413
+
414
+ # ═══════════════════════════════════════════════════════════════
415
+ # STREAMLIT APP
416
+ # ═══════════════════════════════════════════════════════════════
417
+
418
+ st.set_page_config(page_title="EL Defect Detection", page_icon="πŸ”¬", layout="wide")
419
+ st.title("πŸ”¬ EL Defect Detection System")
420
+ st.markdown("**U-Net++ with EfficientNet-B4 | Trained on E-SCDD**")
421
+
422
+ # ── Sidebar ──────────────────────────────────────────────────
423
+ with st.sidebar:
424
+ st.header("βš™οΈ Settings")
425
+
426
+ model_path = st.text_input("Model path", value="output/best_model.pth",
427
+ help="Path to trained .pth file")
428
+
429
+ st.subheader("Quality Thresholds")
430
+ max_score = st.slider("Max defect score", 10, 90, 50, 5)
431
+ max_crack_mm = st.slider("Max crack length (mm)", 5, 100, 30, 5)
432
+ max_dark_pct = st.slider("Max dark area (%)", 5, 80, 40, 5)
433
+ overlay_alpha = st.slider("Overlay opacity", 0.1, 0.9, 0.4, 0.1)
434
+
435
+ st.subheader("Grid Detection")
436
+ min_cells_for_grid = st.slider("Min cells to segment", 2, 12, 4, 1,
437
+ help="Only segment into grid if at least this many cells detected")
438
+
439
+ # ── Load model ───────────────────────────────────────────────
440
+ model, device, meta = load_model(model_path)
441
+ if model is None:
442
+ st.warning(f"⚠️ Model not found at `{model_path}`. Upload an EL image β€” the pipeline "
443
+ f"will still run grid detection and analysis, but segmentation uses fallback heuristics.")
444
+ HAS_MODEL = False
445
+ else:
446
+ st.success(f"βœ… Model loaded: {meta.get('architecture')} + {meta.get('encoder')} | "
447
+ f"Val Dice: {meta.get('val_dice', 0):.4f} | Epoch: {meta.get('epoch', 0)}")
448
+ HAS_MODEL = True
449
+
450
+ # ── Upload ───────────────────────────────────────────────────
451
+ uploaded = st.file_uploader("πŸ“€ Upload EL Image", type=["png", "jpg", "jpeg", "tif", "bmp"])
452
+
453
+ if uploaded:
454
+ pil_img = Image.open(uploaded)
455
+ img_np = np.array(pil_img)
456
+
457
+ # Preprocess
458
+ tensor_input, gray = preprocess_image(img_np, target_size=512)
459
+
460
+ st.markdown("---")
461
+
462
+ # ── Run inference ────────────────────────────────────────
463
+ if HAS_MODEL:
464
+ mask_512 = predict(model, device, tensor_input)
465
+ else:
466
+ # Fallback: simple thresholding
467
+ g = cv2.resize(gray, (512, 512))
468
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
469
+ g = clahe.apply(g)
470
+ mask_512 = np.zeros((512, 512), dtype=np.uint8)
471
+ mean_v = g.mean()
472
+ mask_512[g < mean_v * 0.4] = 3 # dark
473
+ edges = cv2.Canny(g, 30, 100)
474
+ mask_512[edges > 0] = 2 # crack approx
475
+
476
+ # Resize mask to original image size
477
+ mask_full = cv2.resize(mask_512, (gray.shape[1], gray.shape[0]),
478
+ interpolation=cv2.INTER_NEAREST)
479
+
480
+ # Create overlay on original
481
+ overlay_full = create_overlay(gray, mask_full, alpha=overlay_alpha)
482
+
483
+ # ── Display original + overlay ───────────────────────────
484
+ st.subheader("πŸ–ΌοΈ Results")
485
+ col1, col2 = st.columns(2)
486
+ with col1:
487
+ st.markdown("**Original**")
488
+ st.image(gray, use_container_width=True, clamp=True)
489
+ with col2:
490
+ st.markdown("**Defect Overlay**")
491
+ st.image(overlay_full, use_container_width=True, clamp=True)
492
+
493
+ # ── Grid detection + per-cell analysis ───────────────────
494
+ st.markdown("---")
495
+ cells = detect_grid(gray, min_cells=min_cells_for_grid)
496
+ st.subheader(f"πŸ“ {len(cells)} cell(s) detected")
497
+
498
+ # Estimate px/mm from cell size
499
+ if len(cells) > 1:
500
+ widths = [c.bbox[3] - c.bbox[1] for c in cells]
501
+ px_per_mm = np.median(widths) / 156.0 # standard 156mm cell
502
+ else:
503
+ px_per_mm = max(gray.shape) / 156.0
504
+
505
+ # Analyze each cell
506
+ cell_results = []
507
+ cell_overlays = []
508
+
509
+ for cell in cells:
510
+ y1, x1, y2, x2 = cell.bbox
511
+ cell_mask = mask_full[y1:y2, x1:x2]
512
+ cell_gray = gray[y1:y2, x1:x2]
513
+
514
+ result = analyze_cell(cell_gray, cell_mask, px_per_mm=max(px_per_mm, 0.5))
515
+ cell_results.append(result)
516
+
517
+ cell_ov = create_overlay(cell_gray, cell_mask, alpha=overlay_alpha)
518
+ cell_overlays.append(cell_ov)
519
+
520
+ # Display cells in grid
521
+ cols_per_row = min(6, len(cells))
522
+ for row_start in range(0, len(cells), cols_per_row):
523
+ row_end = min(row_start + cols_per_row, len(cells))
524
+ cols = st.columns(cols_per_row)
525
+
526
+ for i, col in enumerate(cols[:row_end - row_start]):
527
+ idx = row_start + i
528
+ r = cell_results[idx]
529
+
530
+ with col:
531
+ st.image(cell_overlays[idx], use_container_width=True, clamp=True)
532
+ score = r["defect_score"]
533
+ icon = "🟒" if score < 25 else ("🟑" if score < 50 else "πŸ”΄")
534
+ st.markdown(f"**Cell {idx+1}** {icon} {score:.0f}")
535
+ st.caption(f"Crack: {r['crack_length_mm']:.1f}mm | Dark: {r['dark_pct']:.1f}%")
536
+
537
+ # ── Module decision ──────────────────────────────────────
538
+ st.markdown("---")
539
+ thresholds = {"max_score": max_score, "max_crack_mm": max_crack_mm, "max_dark_pct": max_dark_pct}
540
+ decision = module_decision(cell_results, thresholds)
541
+
542
+ if decision["decision"] == "PASS":
543
+ st.success(f"βœ… **PASS** β€” Module Score: {decision['score']:.1f}/100")
544
+ else:
545
+ st.error(f"❌ **FAIL** β€” Module Score: {decision['score']:.1f}/100 β€” "
546
+ f"{decision['num_defective']}/{decision['num_cells']} cells defective")
547
+ with st.expander("Failure reasons"):
548
+ for reason in decision["reasons"]:
549
+ st.markdown(f"- {reason}")
550
+
551
+ # ── Summary metrics ──────────────────────────────────────
552
+ st.markdown("---")
553
+ st.subheader("πŸ“Š Summary")
554
+ c1, c2, c3, c4 = st.columns(4)
555
+ c1.metric("Cells", len(cell_results))
556
+ c2.metric("Avg Score", f"{decision['score']:.1f}")
557
+ c3.metric("Total Cracks", sum(r["num_cracks"] for r in cell_results))
558
+ c4.metric("Avg Dark %", f"{np.mean([r['dark_pct'] for r in cell_results]):.1f}%")
559
+
560
+ # ── Detailed table ───────────────────────────────────────
561
+ with st.expander("πŸ“‹ Detailed Results"):
562
+ import pandas as pd
563
+ rows = []
564
+ for i, r in enumerate(cell_results):
565
+ rows.append({
566
+ "Cell": i + 1,
567
+ "Score": r["defect_score"],
568
+ "Cracks": r["num_cracks"],
569
+ "Crack mm": r["crack_length_mm"],
570
+ "Dark %": r["dark_pct"],
571
+ "Busbar %": r["busbar_pct"],
572
+ "Crack Severity": r["crack_severity"],
573
+ "Dark Severity": r["dark_severity"],
574
+ })
575
+ st.dataframe(pd.DataFrame(rows), use_container_width=True)
576
+
577
+ # ── Color legend ─────────────────────────────────────────
578
+ with st.expander("🎨 Color Legend"):
579
+ st.markdown("""
580
+ | Color | Class | Description |
581
+ |-------|-------|-------------|
582
+ | 🟒 Green | Busbar | Metal busbar (feature, not defect) |
583
+ | πŸ”΅ Blue | Crack | Micro-crack in silicon |
584
+ | πŸ”΄ Red | Dark/Inactive | Area disconnected from circuit |
585
+ | 🟑 Yellow | Other Defect | Rings, material, gridline, corrosion, etc. |
586
+ """)
587
+
588
+ # ── Download ─────────────────────────────────────────────
589
+ st.markdown("---")
590
+ col_d1, col_d2 = st.columns(2)
591
+ with col_d1:
592
+ report = {"decision": decision, "cells": cell_results}
593
+ st.download_button("πŸ“„ Download JSON Report",
594
+ json.dumps(report, indent=2),
595
+ "el_report.json", "application/json")
596
+ with col_d2:
597
+ buf = BytesIO()
598
+ Image.fromarray(overlay_full).save(buf, format="PNG")
599
+ st.download_button("πŸ–ΌοΈ Download Overlay",
600
+ buf.getvalue(), "el_overlay.png", "image/png")
601
+
602
+ else:
603
+ st.info("πŸ‘† Upload an EL image to start analysis")
604
+ st.markdown("""
605
+ ### Supported inputs
606
+ - **Full module** images (6Γ—10, 6Γ—12, etc.) β€” automatically segments into cells
607
+ - **Single cell** images β€” analyzed as-is (no grid segmentation)
608
+ - Any brightness, any size, PNG/JPG/TIFF/BMP
609
+
610
+ ### How to train
611
+ ```bash
612
+ python train.py # Downloads E-SCDD + trains U-Net++ on your GPU
613
+ ```
614
+ Then set the model path in sidebar to `output/best_model.pth`
615
+ """)
616
+
617
+ st.markdown("---")
618
+ st.caption("EL Defect Detection | U-Net++ + EfficientNet-B4 + scSE | Dataset: E-SCDD")