nithishbasireddy commited on
Commit
f44ca7f
·
verified ·
1 Parent(s): b73f4b0

Upload src/pipeline/module_segmentation.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/pipeline/module_segmentation.py +503 -0
src/pipeline/module_segmentation.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module Segmentation: Grid Detection & Cell Extraction.
3
+
4
+ This is the CORE PROBLEM of the pipeline. Real-world EL module images contain
5
+ a grid of cells that must be individually extracted for defect analysis.
6
+
7
+ Approach:
8
+ 1. Projection profiles: sum pixel intensities along rows/columns
9
+ → peaks correspond to cell boundaries (dark gaps between cells)
10
+ 2. Peak detection with adaptive parameters
11
+ 3. Spacing analysis: validate peaks using periodicity
12
+ 4. Busbar filtering: busbars create false peaks — detect and exclude them
13
+ 5. Cell extraction: crop individual cells from detected grid
14
+
15
+ Handles:
16
+ - Full modules (6×10, 6×12, etc.)
17
+ - Half-cut cell modules
18
+ - Partial/zoomed images
19
+ - Low-contrast images
20
+ - Missing grid lines
21
+
22
+ Design decision: Projection-based approach over deep learning because:
23
+ - No training data needed for grid detection
24
+ - Deterministic and explainable
25
+ - Works across all module types without retraining
26
+ - Fast enough for real-time use
27
+ """
28
+
29
+ import cv2
30
+ import numpy as np
31
+ from scipy.signal import find_peaks, medfilt
32
+ from scipy.fft import fft, fftfreq
33
+ from typing import List, Tuple, Optional, Dict
34
+ from dataclasses import dataclass, field
35
+
36
+
37
+ @dataclass
38
+ class CellInfo:
39
+ """Information about a single extracted cell."""
40
+ cell_id: int
41
+ row: int
42
+ col: int
43
+ image: np.ndarray # Extracted cell image (grayscale)
44
+ bbox: Tuple[int, int, int, int] # (y1, x1, y2, x2) in original image
45
+ area_pixels: int = 0
46
+
47
+ def to_dict(self) -> dict:
48
+ return {
49
+ "cell_id": self.cell_id,
50
+ "row": self.row,
51
+ "col": self.col,
52
+ "bbox": self.bbox,
53
+ "area_pixels": self.area_pixels,
54
+ }
55
+
56
+
57
+ class ModuleSegmenter:
58
+ """
59
+ Detect cell grid and extract individual cells from EL module images.
60
+
61
+ The algorithm:
62
+ 1. Preprocess: CLAHE + blur for consistent contrast
63
+ 2. Compute row and column projections (inverted: gaps are bright)
64
+ 3. Find peaks in projections = cell boundaries
65
+ 4. Validate peaks using expected periodicity
66
+ 5. Filter busbar false peaks
67
+ 6. Extract cells using detected grid
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ min_cells_per_row: int = 2,
73
+ min_cells_per_col: int = 2,
74
+ peak_prominence_factor: float = 0.15,
75
+ min_cell_size: int = 30,
76
+ busbar_width_ratio: float = 2.5,
77
+ ):
78
+ """
79
+ Args:
80
+ min_cells_per_row: Minimum expected cells per row
81
+ min_cells_per_col: Minimum expected cells per column
82
+ peak_prominence_factor: Fraction of projection range for peak prominence
83
+ min_cell_size: Minimum cell dimension in pixels
84
+ busbar_width_ratio: Peaks wider than median × this ratio are busbars
85
+ """
86
+ self.min_cells_per_row = min_cells_per_row
87
+ self.min_cells_per_col = min_cells_per_col
88
+ self.peak_prominence_factor = peak_prominence_factor
89
+ self.min_cell_size = min_cell_size
90
+ self.busbar_width_ratio = busbar_width_ratio
91
+
92
+ def segment(self, image: np.ndarray) -> List[CellInfo]:
93
+ """
94
+ Main entry point: detect grid and extract cells.
95
+
96
+ Args:
97
+ image: Grayscale EL image (uint8 or float32)
98
+
99
+ Returns:
100
+ List of CellInfo objects, one per detected cell.
101
+ If no grid is detected, returns the whole image as one cell.
102
+ """
103
+ # Ensure grayscale uint8
104
+ gray = self._prepare_image(image)
105
+ h, w = gray.shape
106
+
107
+ # Step 1: Check if this is already a single cell
108
+ if self._is_single_cell(gray):
109
+ return [CellInfo(
110
+ cell_id=1, row=0, col=0, image=gray,
111
+ bbox=(0, 0, h, w), area_pixels=h * w
112
+ )]
113
+
114
+ # Step 2: Compute projection profiles
115
+ row_proj = self._compute_projection(gray, axis=1) # horizontal lines
116
+ col_proj = self._compute_projection(gray, axis=0) # vertical lines
117
+
118
+ # Step 3: Find grid lines
119
+ row_peaks = self._find_grid_lines(row_proj, h, axis="row")
120
+ col_peaks = self._find_grid_lines(col_proj, w, axis="col")
121
+
122
+ # Step 4: Filter busbars (they create wider gaps)
123
+ row_peaks = self._filter_busbars(row_peaks, row_proj)
124
+ col_peaks = self._filter_busbars(col_peaks, col_proj)
125
+
126
+ # Step 5: Validate periodicity
127
+ row_peaks = self._validate_periodicity(row_peaks, h)
128
+ col_peaks = self._validate_periodicity(col_peaks, w)
129
+
130
+ # Step 6: Extract cells
131
+ cells = self._extract_cells(gray, row_peaks, col_peaks)
132
+
133
+ if len(cells) == 0:
134
+ # Fallback: return whole image as one cell
135
+ cells = [CellInfo(
136
+ cell_id=1, row=0, col=0, image=gray,
137
+ bbox=(0, 0, h, w), area_pixels=h * w
138
+ )]
139
+
140
+ return cells
141
+
142
+ def _prepare_image(self, image: np.ndarray) -> np.ndarray:
143
+ """Convert to grayscale uint8 and apply light preprocessing."""
144
+ if image.ndim == 3:
145
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
146
+ elif image.dtype == np.float32 or image.dtype == np.float64:
147
+ if image.max() <= 1.0:
148
+ gray = (image * 255).astype(np.uint8)
149
+ else:
150
+ gray = image.astype(np.uint8)
151
+ else:
152
+ gray = image.astype(np.uint8)
153
+
154
+ # Light CLAHE to improve contrast for grid detection
155
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
156
+ enhanced = clahe.apply(gray)
157
+
158
+ return enhanced
159
+
160
+ def _is_single_cell(self, gray: np.ndarray) -> bool:
161
+ """
162
+ Heuristic: detect if image is already a single cell (no grid).
163
+
164
+ Single cells typically:
165
+ - Are roughly square (aspect ratio close to 1)
166
+ - Have no strong periodic dark gaps
167
+ - Are smaller than typical module images
168
+ """
169
+ h, w = gray.shape
170
+ aspect_ratio = max(h, w) / (min(h, w) + 1)
171
+
172
+ # Very small image is likely a single cell
173
+ if max(h, w) < 200:
174
+ return True
175
+
176
+ # Check for periodic gaps in both directions
177
+ row_proj = self._compute_projection(gray, axis=1)
178
+ col_proj = self._compute_projection(gray, axis=0)
179
+
180
+ # If no clear periodic pattern, likely single cell
181
+ row_period = self._estimate_period(row_proj)
182
+ col_period = self._estimate_period(col_proj)
183
+
184
+ if row_period is None and col_period is None:
185
+ return True
186
+
187
+ # If the estimated period would give < 2 cells, it's a single cell
188
+ if row_period and h / row_period < 2:
189
+ if col_period and w / col_period < 2:
190
+ return True
191
+
192
+ return False
193
+
194
+ def _compute_projection(self, gray: np.ndarray, axis: int) -> np.ndarray:
195
+ """
196
+ Compute intensity projection profile.
197
+
198
+ axis=0: sum along rows → column profile (detect vertical gaps)
199
+ axis=1: sum along columns → row profile (detect horizontal gaps)
200
+
201
+ We INVERT the projection because gaps between cells are DARK,
202
+ so gaps become peaks after inversion.
203
+ """
204
+ # Invert: dark gaps become bright
205
+ inverted = 255 - gray
206
+
207
+ # Sum along axis
208
+ projection = inverted.astype(np.float64).mean(axis=axis)
209
+
210
+ # Smooth to reduce noise
211
+ kernel_size = max(3, len(projection) // 100)
212
+ if kernel_size % 2 == 0:
213
+ kernel_size += 1
214
+ projection = medfilt(projection, kernel_size=kernel_size)
215
+
216
+ return projection
217
+
218
+ def _estimate_period(self, projection: np.ndarray) -> Optional[int]:
219
+ """
220
+ Estimate periodicity of projection using FFT.
221
+
222
+ Returns estimated period in pixels, or None if no clear period.
223
+ """
224
+ n = len(projection)
225
+ if n < 20:
226
+ return None
227
+
228
+ # Remove DC component
229
+ proj_centered = projection - projection.mean()
230
+
231
+ # FFT
232
+ fft_vals = np.abs(fft(proj_centered))
233
+ freqs = fftfreq(n)
234
+
235
+ # Only look at positive frequencies, skip DC
236
+ pos_mask = freqs > 0
237
+ fft_pos = fft_vals[pos_mask]
238
+ freq_pos = freqs[pos_mask]
239
+
240
+ if len(fft_pos) == 0:
241
+ return None
242
+
243
+ # Find dominant frequency
244
+ peak_idx = np.argmax(fft_pos)
245
+ dominant_freq = freq_pos[peak_idx]
246
+
247
+ if dominant_freq <= 0:
248
+ return None
249
+
250
+ period = int(1.0 / dominant_freq)
251
+
252
+ # Validate: period should be reasonable (10-50% of image dimension)
253
+ if period < n * 0.05 or period > n * 0.6:
254
+ return None
255
+
256
+ return period
257
+
258
+ def _find_grid_lines(
259
+ self, projection: np.ndarray, dim_size: int, axis: str
260
+ ) -> np.ndarray:
261
+ """
262
+ Find peaks in projection profile = cell boundaries.
263
+
264
+ Uses adaptive parameters based on projection statistics.
265
+ """
266
+ if len(projection) < 10:
267
+ return np.array([], dtype=int)
268
+
269
+ # Adaptive parameters
270
+ proj_range = projection.max() - projection.min()
271
+ prominence = proj_range * self.peak_prominence_factor
272
+
273
+ # Estimate minimum distance between peaks
274
+ period = self._estimate_period(projection)
275
+ if period is not None:
276
+ min_distance = max(int(period * 0.5), self.min_cell_size)
277
+ else:
278
+ # Fallback: assume at least 4 cells
279
+ min_distance = max(dim_size // 20, self.min_cell_size)
280
+
281
+ # Find peaks
282
+ peaks, properties = find_peaks(
283
+ projection,
284
+ prominence=prominence,
285
+ distance=min_distance,
286
+ height=projection.mean(), # peaks must be above average
287
+ )
288
+
289
+ # If too few peaks found, try with relaxed parameters
290
+ if len(peaks) < 2:
291
+ peaks, properties = find_peaks(
292
+ projection,
293
+ prominence=proj_range * 0.05, # much lower threshold
294
+ distance=max(dim_size // 30, 10),
295
+ )
296
+
297
+ return peaks
298
+
299
+ def _filter_busbars(
300
+ self, peaks: np.ndarray, projection: np.ndarray
301
+ ) -> np.ndarray:
302
+ """
303
+ Filter out busbar peaks.
304
+
305
+ Busbars create WIDER gaps than cell spacing.
306
+ We detect them by comparing peak widths to the median width.
307
+
308
+ Strategy: remove peaks whose "width at half prominence" exceeds
309
+ median_width × busbar_width_ratio.
310
+ """
311
+ if len(peaks) < 3:
312
+ return peaks
313
+
314
+ # Estimate peak widths
315
+ widths = []
316
+ for peak in peaks:
317
+ # Find width at half height
318
+ half_height = (projection[peak] + projection.min()) / 2
319
+
320
+ # Search left
321
+ left = peak
322
+ while left > 0 and projection[left] > half_height:
323
+ left -= 1
324
+
325
+ # Search right
326
+ right = peak
327
+ while right < len(projection) - 1 and projection[right] > half_height:
328
+ right += 1
329
+
330
+ widths.append(right - left)
331
+
332
+ widths = np.array(widths)
333
+ median_width = np.median(widths)
334
+
335
+ # Keep peaks with reasonable width
336
+ mask = widths < median_width * self.busbar_width_ratio
337
+
338
+ return peaks[mask]
339
+
340
+ def _validate_periodicity(
341
+ self, peaks: np.ndarray, dim_size: int
342
+ ) -> np.ndarray:
343
+ """
344
+ Validate peaks by checking for periodic spacing.
345
+
346
+ Removes outlier peaks that don't fit the dominant spacing pattern.
347
+ This handles noise-induced false peaks.
348
+ """
349
+ if len(peaks) < 3:
350
+ return peaks
351
+
352
+ # Compute spacings between consecutive peaks
353
+ spacings = np.diff(peaks)
354
+
355
+ if len(spacings) == 0:
356
+ return peaks
357
+
358
+ median_spacing = np.median(spacings)
359
+
360
+ if median_spacing < self.min_cell_size:
361
+ return peaks
362
+
363
+ # Filter: keep spacings within 50% of median
364
+ valid_mask = np.ones(len(peaks), dtype=bool)
365
+ for i in range(len(spacings)):
366
+ if abs(spacings[i] - median_spacing) > median_spacing * 0.5:
367
+ # This spacing is suspicious — remove the peak that causes it
368
+ # Keep the peak that's more consistent with neighbors
369
+ if i > 0 and i < len(spacings) - 1:
370
+ prev_ok = abs(spacings[i-1] - median_spacing) < median_spacing * 0.3
371
+ if prev_ok:
372
+ valid_mask[i + 1] = False
373
+ else:
374
+ valid_mask[i] = False
375
+
376
+ return peaks[valid_mask]
377
+
378
+ def _extract_cells(
379
+ self, gray: np.ndarray, row_peaks: np.ndarray, col_peaks: np.ndarray
380
+ ) -> List[CellInfo]:
381
+ """
382
+ Extract individual cells from detected grid lines.
383
+
384
+ Row peaks = horizontal boundaries
385
+ Col peaks = vertical boundaries
386
+ """
387
+ h, w = gray.shape
388
+ cells = []
389
+
390
+ # Add image boundaries
391
+ row_bounds = np.concatenate([[0], row_peaks, [h]])
392
+ col_bounds = np.concatenate([[0], col_peaks, [w]])
393
+
394
+ # Remove duplicate/close boundaries
395
+ row_bounds = self._merge_close_bounds(row_bounds, self.min_cell_size // 2)
396
+ col_bounds = self._merge_close_bounds(col_bounds, self.min_cell_size // 2)
397
+
398
+ cell_id = 1
399
+ for i in range(len(row_bounds) - 1):
400
+ for j in range(len(col_bounds) - 1):
401
+ y1, y2 = int(row_bounds[i]), int(row_bounds[i + 1])
402
+ x1, x2 = int(col_bounds[j]), int(col_bounds[j + 1])
403
+
404
+ # Minimum size check
405
+ if y2 - y1 < self.min_cell_size or x2 - x1 < self.min_cell_size:
406
+ continue
407
+
408
+ cell_img = gray[y1:y2, x1:x2]
409
+
410
+ # Skip cells that are mostly background (very dark)
411
+ if cell_img.mean() < 10:
412
+ continue
413
+
414
+ cells.append(CellInfo(
415
+ cell_id=cell_id,
416
+ row=i,
417
+ col=j,
418
+ image=cell_img.copy(),
419
+ bbox=(y1, x1, y2, x2),
420
+ area_pixels=(y2 - y1) * (x2 - x1),
421
+ ))
422
+ cell_id += 1
423
+
424
+ return cells
425
+
426
+ def _merge_close_bounds(
427
+ self, bounds: np.ndarray, min_gap: int
428
+ ) -> np.ndarray:
429
+ """Merge boundaries that are too close together."""
430
+ if len(bounds) <= 1:
431
+ return bounds
432
+
433
+ merged = [bounds[0]]
434
+ for b in bounds[1:]:
435
+ if b - merged[-1] >= min_gap:
436
+ merged.append(b)
437
+ else:
438
+ # Replace with midpoint
439
+ merged[-1] = (merged[-1] + b) // 2
440
+
441
+ return np.array(merged)
442
+
443
+ def get_grid_visualization(
444
+ self, image: np.ndarray, cells: List[CellInfo]
445
+ ) -> np.ndarray:
446
+ """
447
+ Draw detected grid on image for visualization.
448
+
449
+ Returns BGR image with colored cell boundaries.
450
+ """
451
+ if image.ndim == 2:
452
+ vis = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
453
+ else:
454
+ vis = image.copy()
455
+
456
+ for cell in cells:
457
+ y1, x1, y2, x2 = cell.bbox
458
+ cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 2)
459
+ cv2.putText(
460
+ vis, f"C{cell.cell_id}", (x1 + 5, y1 + 20),
461
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1
462
+ )
463
+
464
+ return vis
465
+
466
+
467
+ def estimate_pixel_to_mm(
468
+ cell_width_px: int,
469
+ cell_height_px: int,
470
+ cell_type: str = "standard",
471
+ ) -> float:
472
+ """
473
+ Estimate pixel-to-mm conversion factor from cell dimensions.
474
+
475
+ Standard crystalline silicon solar cells:
476
+ - Full cell: 156mm × 156mm (M2) or 166mm × 166mm (M6) or 182mm × 182mm (M10)
477
+ - Half-cut cell: 156mm × 78mm (M2) or 166mm × 83mm (M6)
478
+
479
+ Args:
480
+ cell_width_px: Cell width in pixels
481
+ cell_height_px: Cell height in pixels
482
+ cell_type: 'standard' (156mm), 'M6' (166mm), 'M10' (182mm)
483
+
484
+ Returns:
485
+ Conversion factor: mm per pixel
486
+ """
487
+ cell_sizes_mm = {
488
+ "standard": 156.0,
489
+ "M2": 156.0,
490
+ "M6": 166.0,
491
+ "M10": 182.0,
492
+ "M12": 210.0,
493
+ }
494
+
495
+ physical_size = cell_sizes_mm.get(cell_type, 156.0)
496
+
497
+ # Use the larger dimension (cells are roughly square)
498
+ max_px = max(cell_width_px, cell_height_px)
499
+
500
+ if max_px == 0:
501
+ return 1.0 # Fallback
502
+
503
+ return physical_size / max_px