py2ls 0.2.5.10__py3-none-any.whl → 0.2.5.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
py2ls/ich2ls.py CHANGED
@@ -1,331 +1,708 @@
1
+ # 用来处理ich图像的初级工具包
2
+
1
3
  import numpy as np
2
4
  import pandas as pd
3
5
  import matplotlib.pyplot as plt
4
- from scipy.stats import pearsonr
6
+ from typing import Union, Tuple, List, Dict, Optional
5
7
  from PIL import Image
6
- from skimage import filters, morphology, measure, color
7
-
8
- # 用来处理ich图像的初级工具包
9
-
10
-
11
- def open_img(dir_img, convert="gray", plot=False):
12
- # Step 1: Load the image
13
- image = Image.open(dir_img)
14
-
15
- if convert == "gray" or convert == "grey":
16
- gray_image = image.convert("L")
17
- image_array = np.array(gray_image)
8
+ from skimage import (filters, morphology, measure, color,
9
+ segmentation, exposure, util)
10
+ from skimage.filters import threshold_multiotsu
11
+ from scipy import ndimage as ndi
12
+ import warnings
13
+ from .ips import color2rgb
14
+ # Suppress specific warnings
15
+ warnings.filterwarnings('ignore', category=UserWarning)
16
+ warnings.filterwarnings('ignore', category=FutureWarning)
17
+
18
+ def open_img(
19
+ img_path: str,
20
+ convert: str = "gray",
21
+ plot: bool = False,
22
+ figsize: Tuple[int, int] = (10, 5)
23
+ ) -> Tuple[Image.Image, np.ndarray]:
24
+ """
25
+ Enhanced image loading with better conversion options and visualization
26
+
27
+ Args:
28
+ img_path: Path to image file
29
+ convert: Conversion mode ('gray', 'rgb', 'hed', 'hsv')
30
+ plot: Whether to show comparison plot
31
+ figsize: Size of comparison plot
32
+
33
+ Returns:
34
+ Tuple of (PIL Image, numpy array)
35
+ """
36
+ # Load image with validation
37
+ try:
38
+ img = Image.open(img_path)
39
+ except Exception as e:
40
+ raise ValueError(f"Failed to load image: {str(e)}")
41
+
42
+ # Enhanced conversion options
43
+ if convert.lower() in ["gray", "grey"]:
44
+ img_array = np.array(img.convert("L"))
45
+ elif convert.lower() == "rgb":
46
+ img_array = np.array(img.convert("RGB"))
47
+ elif convert.lower() == "hed":
48
+ img_array = color.rgb2hed(np.array(img.convert("RGB")))
49
+ elif convert.lower() == "hsv":
50
+ img_array = color.rgb2hsv(np.array(img.convert("RGB")))
18
51
  else:
19
- image_array = np.array(image)
52
+ img_array = np.array(img)
53
+
54
+ # Visualization
20
55
  if plot:
21
- _, axs = plt.subplots(1, 2)
22
- axs[0].imshow(image)
23
- axs[1].imshow(image_array)
24
- axs[0].set_title("img_raw")
25
- axs[1].set_title(f"img_{convert}")
26
- return image, image_array
27
-
28
-
29
- from skimage import filters, morphology
56
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
57
+ ax1.imshow(img)
58
+ ax1.set_title("Original Image")
59
+ ax1.axis('off')
60
+
61
+ ax2.imshow(img_array, cmap='gray' if convert.lower() in ["gray", "grey"] else None)
62
+ ax2.set_title(f"Converted ({convert.upper()})")
63
+ ax2.axis('off')
64
+ plt.tight_layout()
65
+ plt.show()
66
+
67
+ return img, img_array
30
68
 
31
69
 
32
70
  def clean_img(
33
- img,
34
- method=["threshold_otsu", "objects", "holes"],
35
- obj_min=50,
36
- hole_min=50,
37
- filter=None,
38
- plot=False,
39
- cmap="grey",
40
- ):
41
- if isinstance(method, str):
42
- if method == "all":
43
- method = ["threshold_otsu", "objects", "holes"]
71
+ img: np.ndarray,
72
+ methods: Union[str, List[str]] = "all",
73
+ obj_min: int = 50,
74
+ hole_min: int = 50,
75
+ threshold_range: Optional[Tuple[float, float]] = None,
76
+ plot: bool = False,
77
+ cmap: str = "gray",
78
+ figsize: Tuple[int, int] = (8, 8),
79
+ **kwargs
80
+ ) -> np.ndarray:
81
+ """
82
+ Advanced image cleaning with multiple processing methods
83
+
84
+ Args:
85
+ img: Input image array
86
+ methods: Processing methods ('threshold', 'objects', 'holes', 'all')
87
+ obj_min: Minimum object size (pixels)
88
+ hole_min: Minimum hole size (pixels)
89
+ threshold_range: Manual threshold range [min, max]
90
+ plot: Whether to show result
91
+ cmap: Colormap for plotting
92
+ figsize: Figure size for plotting
93
+
94
+ Returns:
95
+ Cleaned binary image
96
+ """
97
+ # Process methods argument
98
+ if isinstance(methods, str):
99
+ methods = ["threshold", "objects", "holes"] if methods == "all" else [methods]
100
+
101
+ img_clean = img.copy()
102
+
103
+ # Apply thresholding
104
+ if any(m in methods for m in ["threshold", "thr", "otsu"]):
105
+ if threshold_range:
106
+ # Manual threshold range
107
+ img_clean = np.logical_and(img >= threshold_range[0], img <= threshold_range[1])
44
108
  else:
45
- method = [method]
46
- if any("thr" in met or "ot" in met for met in method) and filter is None:
47
- thr_otsu = filters.threshold_otsu(img)
48
- img_update = img > thr_otsu
49
- if any("obj" in met for met in method):
50
- img_update = morphology.remove_small_objects(img_update, min_size=obj_min)
51
- if any("hol" in met for met in method):
52
- img_update = morphology.remove_small_holes(img_update, area_threshold=hole_min)
53
- if ("thr" in met for met in method) and filter: # threshold
54
- mask = (img >= filter[0]) & (img <= filter[1])
55
- img_update = np.where(mask, img, 0)
56
-
109
+ try:
110
+ # Try multi-Otsu first for better thresholding
111
+ thresholds = threshold_multiotsu(img)
112
+ img_clean = img > thresholds[0]
113
+ except ValueError:
114
+ # Fallback to regular Otsu
115
+ thr = filters.threshold_otsu(img)
116
+ img_clean = img > thr
117
+
118
+ # Morphological operations
119
+ if any(m in methods for m in ["objects", "obj"]):
120
+ img_clean = morphology.remove_small_objects(img_clean, min_size=obj_min)
121
+
122
+ if any(m in methods for m in ["holes", "hole"]):
123
+ img_clean = morphology.remove_small_holes(img_clean, area_threshold=hole_min)
124
+
125
+ # Optional additional processing
126
+ if kwargs.get("denoise", False):
127
+ img_clean = filters.median(img_clean)
128
+
129
+ if kwargs.get("close", False):
130
+ img_clean = morphology.binary_closing(img_clean)
131
+
132
+ # Visualization
57
133
  if plot:
58
- plt.imshow(img_update, cmap=cmap)
59
- return img_update
60
-
61
-
62
- from skimage import filters, segmentation
134
+ plt.figure(figsize=figsize)
135
+ plt.imshow(img_clean, cmap=cmap)
136
+ plt.title("Cleaned Image")
137
+ plt.axis('off')
138
+ plt.show()
139
+
140
+ return img_clean
63
141
 
64
142
 
65
143
  def segment_img(
66
- img,
67
- filter=[30, 150],
68
- plot=False,
69
- mode="reflect", # 'reflect' or 'constant'
70
- method="region", # 'region' or 'edge', 'threshold'
71
- area_min=50,
72
- cmap="jet",
73
- connectivity=1,
74
- output="segmentation",
75
- ):
76
- if "reg" in method: # region method
77
- # 1. find an elevation map using the Sobel gradient of the image
78
- elevation_map = filters.sobel(img, mode=mode)
79
- # 2. find markers of the background and the coins based on the extreme parts of the histogram of gray values.
80
- markers = np.zeros_like(img)
81
- # Apply filtering based on provided filter values
82
- if filter is not None:
83
- markers[img < filter[0]] = 1
84
- markers[img > filter[1]] = 2
144
+ img: np.ndarray,
145
+ method: str = "watershed",
146
+ min_size: int = 50,
147
+ plot: bool = False,
148
+ cmap: str = "jet",
149
+ connectivity: int = 1,
150
+ output: str = "segmentation",
151
+ **kwargs
152
+ ) -> Union[np.ndarray, Dict[str, np.ndarray]]:
153
+ """
154
+ Advanced image segmentation with multiple algorithms
155
+
156
+ Args:
157
+ img: Input image
158
+ method: Segmentation method ('watershed', 'edge', 'threshold')
159
+ min_size: Minimum region size
160
+ plot: Whether to show results
161
+ cmap: Colormap for visualization
162
+ connectivity: Pixel connectivity for watershed
163
+ output: Output type ('segmentation', 'all', 'edges', 'markers')
164
+
165
+ Returns:
166
+ Segmented image or dictionary of intermediate results
167
+ """
168
+ results = {}
169
+
170
+ if method.lower() in ["watershed", "region", "watershed"]:
171
+ # Enhanced watershed segmentation
172
+ elevation_map = filters.sobel(img)
173
+
174
+ # Adaptive marker generation
175
+ if kwargs.get("adaptive_markers", True):
176
+ # Use histogram peaks for better marker placement
177
+ hist, bins = np.histogram(img, bins=256)
178
+ peaks = np.argsort(hist)[-2:] # Get two highest peaks
179
+ markers = np.zeros_like(img)
180
+ markers[img < bins[peaks[0]]] = 1
181
+ markers[img > bins[peaks[1]]] = 2
85
182
  else:
86
- # If no filter is provided, set markers across the whole range of the image
87
- markers[img == img.min()] = 1
88
- markers[img == img.max()] = 2
89
- # 3. watershed transform to fill regions of the elevation map starting from the markers
90
- img_segmentation = segmentation.watershed(
91
- elevation_map, markers=markers, connectivity=connectivity
183
+ # Simple threshold-based markers
184
+ markers = np.zeros_like(img)
185
+ markers[img < np.percentile(img, 25)] = 1
186
+ markers[img > np.percentile(img, 75)] = 2
187
+
188
+ # Apply watershed
189
+ segmentation_result = segmentation.watershed(
190
+ elevation_map,
191
+ markers=markers,
192
+ connectivity=connectivity
92
193
  )
93
- if plot:
94
- _, axs = plt.subplots(2, 2)
95
- for i, ax in enumerate(axs.flatten().tolist()):
96
- if i == 0:
97
- ax.imshow(img)
98
- ax.set_title("image")
99
- elif i == 1:
100
- ax.imshow(elevation_map, cmap=cmap)
101
- ax.set_title("elevation map")
102
- elif i == 2:
103
- ax.imshow(markers, cmap=cmap)
104
- ax.set_title("markers")
105
- elif i == 3:
106
- ax.imshow(img_segmentation, cmap=cmap)
107
- ax.set_title("segmentation")
108
- ax.set_axis_off()
109
- if "el" in output:
110
- return elevation_map
111
- elif "mar" in output:
112
- return markers
113
- elif "seg" in output:
114
- return img_segmentation
115
- else:
116
- return img_segmentation
117
- elif "ed" in method: # edge
118
- edges = cal_edges(img)
119
- fills = fill_holes(edges)
120
- img_segmentation = remove_holes(fills, area_min)
121
- if plot:
122
- _, axs = plt.subplots(2, 2)
123
- for i, ax in enumerate(axs.flatten().tolist()):
124
- if i == 0:
125
- ax.imshow(img)
126
- ax.set_title("image")
127
- elif i == 1:
128
- ax.imshow(edges, cmap=cmap)
129
- ax.set_title("edges map")
130
- elif i == 2:
131
- ax.imshow(fills, cmap=cmap)
132
- ax.set_title("fills")
133
- elif i == 3:
134
- ax.imshow(img_segmentation, cmap=cmap)
135
- ax.set_title("segmentation")
136
- ax.set_axis_off()
137
- if "seg" in output:
138
- return img_segmentation
139
- elif "ed" in output:
140
- return edges
141
- elif "fill" in output:
142
- return fills
143
- else:
144
- return img_segmentation
145
- elif "thr" in method: # threshold
146
- if filter:
147
- mask = (img >= filter[0]) & (img <= filter[1])
148
- img_threshold = np.where(mask, img, 0)
149
- if plot:
150
- plt.imshow(img_threshold, cmap=cmap)
151
- return img_threshold
194
+
195
+ # Clean up small regions
196
+ segmentation_result = morphology.remove_small_objects(
197
+ segmentation_result,
198
+ min_size=min_size
199
+ )
200
+
201
+ results = {
202
+ "elevation": elevation_map,
203
+ "markers": markers,
204
+ "segmentation": segmentation_result
205
+ }
206
+
207
+ elif method.lower() in ["edge", "edges"]:
208
+ # Edge-based segmentation
209
+ edges = filters.sobel(img) > 0.1
210
+ filled = ndi.binary_fill_holes(edges)
211
+ segmentation_result = morphology.remove_small_objects(filled, min_size=min_size)
212
+
213
+ results = {
214
+ "edges": edges,
215
+ "filled": filled,
216
+ "segmentation": segmentation_result
217
+ }
218
+
219
+ elif method.lower() in ["threshold", "thr"]:
220
+ # Threshold-based segmentation
221
+ if "threshold_range" in kwargs:
222
+ low, high = kwargs["threshold_range"]
223
+ segmentation_result = np.logical_and(img >= low, img <= high)
152
224
  else:
153
- return None
154
-
155
-
156
- from skimage import measure
225
+ try:
226
+ thresholds = threshold_multiotsu(img)
227
+ segmentation_result = np.digitize(img, bins=thresholds)
228
+ except ValueError:
229
+ threshold = filters.threshold_otsu(img)
230
+ segmentation_result = img > threshold
231
+
232
+ results = {
233
+ "segmentation": segmentation_result
234
+ }
235
+
236
+ # Visualization
237
+ if plot:
238
+ n_results = len(results)
239
+ fig, axes = plt.subplots(1, n_results + 1, figsize=((n_results + 1) * 5, 5))
240
+
241
+ axes[0].imshow(img, cmap='gray')
242
+ axes[0].set_title("Original")
243
+ axes[0].axis('off')
244
+
245
+ for i, (name, result) in enumerate(results.items(), 1):
246
+ axes[i].imshow(result, cmap=cmap)
247
+ axes[i].set_title(name.capitalize())
248
+ axes[i].axis('off')
249
+
250
+ plt.tight_layout()
251
+ plt.show()
252
+
253
+ # Return requested output
254
+ if output == "all":
255
+ return results
256
+ elif output in results:
257
+ return results[output]
258
+ else:
259
+ return results["segmentation"]
157
260
 
158
261
 
159
- def label_img(img, plot=False):
160
- img_label = measure.label(img)
262
+ def label_img(
263
+ img: np.ndarray,
264
+ plot: bool = False,
265
+ cmap: str = "nipy_spectral",
266
+ min_size: int = 20,
267
+ **kwargs
268
+ ) -> np.ndarray:
269
+ """
270
+ Enhanced connected component labeling
271
+
272
+ Args:
273
+ img: Binary input image
274
+ plot: Whether to visualize results
275
+ cmap: Colormap for labels
276
+ min_size: Minimum region size
277
+
278
+ Returns:
279
+ Labeled image
280
+ """
281
+ # Clean image first
282
+ img_clean = clean_img(
283
+ img,
284
+ methods=["objects", "holes"],
285
+ obj_min=min_size,
286
+ hole_min=min_size
287
+ )
288
+
289
+ # Label connected components
290
+ labels = measure.label(img_clean)
291
+
292
+ # Visualization
161
293
  if plot:
162
- plt.imshow(img_label)
163
- return img_label
164
-
165
-
166
- def img_process(img, **kwargs):
167
- convert = "gray"
168
- method_clean_img = ["threshold_otsu", "objects", "holes"]
169
- obj_min_clean_img = 50
170
- hole_min_clean_img = 50
171
- plot = True
172
- for k, v in kwargs.items():
173
- if "convert" in k.lower():
174
- convert = v
175
- if "met" in k.lower() and any(
176
- ["clean" in k.lower(), "rem" in k.lower(), "rm" in k.lower()]
177
- ):
178
- method_clean_img = v
179
- if "obj" in k.lower() and any(
180
- ["clean" in k.lower(), "rem" in k.lower(), "rm" in k.lower()]
181
- ):
182
- obj_min_clean_img = v
183
- if "hol" in k.lower() and any(
184
- ["clean" in k.lower(), "rem" in k.lower(), "rm" in k.lower()]
185
- ):
186
- hole_min_clean_img = v
187
- if "plot" in k.lower():
188
- plot = v
189
-
294
+ plt.figure(figsize=(10, 5))
295
+ plt.imshow(labels, cmap=cmap)
296
+ plt.title("Labeled Regions")
297
+ plt.colorbar()
298
+ plt.axis('off')
299
+ plt.show()
300
+
301
+ return labels
302
+
303
+
304
+ def img_process(
305
+ img: Union[str, np.ndarray],
306
+ convert: str = "gray",
307
+ clean_methods: Union[str, List[str]] = "all",
308
+ clean_params: Optional[Dict] = None,
309
+ segment_method: str = "watershed",
310
+ segment_params: Optional[Dict] = None,
311
+ label_params: Optional[Dict] = None,
312
+ plot: bool = True,
313
+ figsize: Tuple[int, int] = (15, 10),
314
+ return_all: bool = False
315
+ ) -> Dict[str, Union[np.ndarray, pd.DataFrame]]:
316
+ """
317
+ Complete image processing pipeline with enhanced functionality
318
+
319
+ Args:
320
+ img: Input image path or array
321
+ convert: Color conversion mode
322
+ clean_methods: Cleaning methods
323
+ clean_params: Parameters for cleaning
324
+ segment_method: Segmentation method
325
+ segment_params: Parameters for segmentation
326
+ label_params: Parameters for labeling
327
+ plot: Whether to show processing steps
328
+ figsize: Figure size for plots
329
+ return_all: Whether to return all intermediate results
330
+
331
+ Returns:
332
+ Dictionary containing processed images and region properties
333
+ """
334
+ # Initialize parameters
335
+ clean_params = clean_params or {}
336
+ segment_params = segment_params or {}
337
+ label_params = label_params or {}
338
+
339
+ # Load image if path is provided
190
340
  if isinstance(img, str):
191
- image, image_array = open_img(img, convert=convert)
192
- normalized_image = image_array / 255.0
341
+ pil_img, img_array = open_img(img, convert=convert, plot=plot)
193
342
  else:
194
- cleaned_image = img
195
- image_array = cleaned_image
196
- normalized_image = cleaned_image
197
- image = cleaned_image
198
-
199
- # Remove small objects and fill small holes
200
- cleaned_image = clean_img(
201
- img=image_array,
202
- method=method_clean_img,
203
- obj_min=obj_min_clean_img,
204
- hole_min=hole_min_clean_img,
205
- plot=False,
343
+ pil_img = None
344
+ img_array = img
345
+
346
+ # Normalize image
347
+ img_norm = exposure.rescale_intensity(img_array)
348
+
349
+ # Clean image
350
+ img_clean = clean_img(
351
+ img_norm,
352
+ methods=clean_methods,
353
+ plot=plot,
354
+ **clean_params
206
355
  )
207
- # Label the regions
208
- label_image = label_img(cleaned_image)
209
- overlay_image = overlay_imgs(label_image, image=image_array)
210
- regions = measure.regionprops(label_image, intensity_image=image_array)
211
- region_props = measure.regionprops_table(
212
- label_image, intensity_image=image_array, properties=props_list
356
+
357
+ # Segment image
358
+ seg_results = segment_img(
359
+ img_clean,
360
+ method=segment_method,
361
+ plot=plot,
362
+ **segment_params
213
363
  )
214
- df_regions = pd.DataFrame(region_props)
215
- # Pack the results into a single output variable (dictionary)
364
+
365
+ # Get segmentation result (handle case where multiple outputs are returned)
366
+ if isinstance(seg_results, dict):
367
+ img_seg = seg_results["segmentation"]
368
+ else:
369
+ img_seg = seg_results
370
+
371
+ # Label image
372
+ img_label = label_img(
373
+ img_seg,
374
+ plot=plot,
375
+ **label_params
376
+ )
377
+
378
+ # Calculate region properties
379
+ regions = measure.regionprops(img_label, intensity_image=img_norm)
380
+
381
+ # Create DataFrame of properties
382
+ props_list = [
383
+ 'area', 'bbox', 'centroid', 'convex_area', 'eccentricity',
384
+ 'equivalent_diameter', 'euler_number', 'extent', 'filled_area',
385
+ 'label', 'major_axis_length', 'max_intensity', 'mean_intensity',
386
+ 'min_intensity', 'minor_axis_length', 'orientation', 'perimeter',
387
+ 'solidity', 'weighted_centroid'
388
+ ]
389
+
390
+ region_table = measure.regionprops_table(
391
+ img_label,
392
+ intensity_image=img_norm,
393
+ properties=props_list
394
+ )
395
+ df_regions = pd.DataFrame(region_table)
396
+
397
+ # Prepare output
216
398
  output = {
217
- "img": image,
218
- "img_array": image_array,
219
- "img_scale": normalized_image,
220
- "img_clean": cleaned_image,
221
- "img_label": label_image,
222
- "img_overlay": overlay_image,
399
+ "original": pil_img if pil_img is not None else img_array,
400
+ "array": img_array,
401
+ "normalized": img_norm,
402
+ "cleaned": img_clean,
403
+ "segmentation": img_seg,
404
+ "labeled": img_label,
223
405
  "regions": regions,
224
- "df_regions": df_regions,
406
+ "df_regions": df_regions
225
407
  }
408
+
409
+ # Add intermediate results if requested
410
+ if return_all and isinstance(seg_results, dict):
411
+ output.update(seg_results)
412
+
413
+ # Visualization of final results
226
414
  if plot:
227
- imgs = []
228
- [imgs.append(i) for i in list(output.keys()) if "img" in i]
229
- for img_ in imgs:
230
- plt.figure()
231
- plt.imshow(output[img_])
232
- plt.title(img_)
415
+ fig, axes = plt.subplots(2, 3, figsize=figsize)
416
+ titles = ["Original", "Normalized", "Cleaned", "Segmentation", "Labeled", "Overlay"]
417
+
418
+ # Handle different image types
419
+ display_images = [
420
+ output["original"] if pil_img is not None else output["array"],
421
+ output["normalized"],
422
+ output["cleaned"],
423
+ output["segmentation"],
424
+ output["labeled"],
425
+ color.label2rgb(output["labeled"], image=output["normalized"], alpha=0.3)
426
+ ]
427
+
428
+ for ax, title, disp_img in zip(axes.flatten(), titles, display_images):
429
+ ax.imshow(disp_img, cmap='gray' if title != "Labeled" else 'nipy_spectral')
430
+ ax.set_title(title)
431
+ ax.axis('off')
432
+
433
+ plt.tight_layout()
434
+ plt.show()
435
+
233
436
  return output
234
437
 
235
438
 
236
- # def img_preprocess(dir_img, subtract_background=True, size_obj=50, size_hole=50,**kwargs):
237
- # """
238
- # Processes an image by performing thresholding, morphological operations,
239
- # and region labeling.
240
-
241
- # Parameters:
242
- # - dir_img: Path to the image file.
243
- # - size_obj: Minimum size of objects to keep (default: 50).
244
- # - size_hole: Maximum size of holes to fill (default: 50).
245
-
246
- # Returns:
247
- # - output: Dictionary containing the overlay image, threshold value, and regions.
248
- # """
249
- # props_list = [
250
- # "area", # Number of pixels in the region. Useful for determining the size of regions.
251
- # "area_bbox",
252
- # "area_convex",
253
- # "area_filled",
254
- # "axis_major_length", # Lengths of the major and minor axes of the ellipse that fits the region. Useful for understanding the shape's elongation and orientation.
255
- # "axis_minor_length",
256
- # "bbox", # Bounding box coordinates (min_row, min_col, max_row, max_col). Useful for spatial localization of regions.
257
- # "centroid", # Center of mass coordinates (centroid-0, centroid-1). Helps locate the center of each region.
258
- # "centroid_local",
259
- # "centroid_weighted",
260
- # "centroid_weighted_local",
261
- # "coords",
262
- # "eccentricity", # Measure of how elongated the region is. Values range from 0 (circular) to 1 (line). Useful for assessing the shape of regions.
263
- # "equivalent_diameter_area", # Diameter of a circle with the same area as the region. Provides a simple measure of size.
264
- # "euler_number",
265
- # "extent", # Ratio of the region's area to the area of its bounding box. Indicates how much of the bounding box is filled by the region.
266
- # "feret_diameter_max", # Maximum diameter of the region, providing another measure of size.
267
- # "image",
268
- # "image_convex",
269
- # "image_filled",
270
- # "image_intensity",
271
- # "inertia_tensor", # ensor describing the distribution of mass in the region, useful for more advanced shape analysis.
272
- # "inertia_tensor_eigvals",
273
- # "intensity_max", # Maximum intensity value within the region. Helps identify regions with high-intensity features.
274
- # "intensity_mean", # Average intensity value within the region. Useful for distinguishing between regions based on their brightness.
275
- # "intensity_min", # Minimum intensity value within the region. Useful for regions with varying intensity.
276
- # "intensity_std",
277
- # "label", # Unique identifier for each region.
278
- # "moments",
279
- # "moments_central",
280
- # "moments_hu", # Hu moments are a set of seven invariant features that describe the shape of the region. Useful for shape recognition and classification.
281
- # "moments_normalized",
282
- # "moments_weighted",
283
- # "moments_weighted_central",
284
- # "moments_weighted_hu",
285
- # "moments_weighted_normalized",
286
- # "orientation", # ngle of the major axis of the ellipse that fits the region. Useful for determining the orientation of elongated regions.
287
- # "perimeter", # Length of the boundary of the region. Useful for shape analysis.
288
- # "perimeter_crofton",
289
- # "slice",
290
- # "solidity", # Ratio of the area of the region to the area of its convex hull. Indicates how solid or compact a region is.
291
- # ]
292
- # if isinstance(dir_img, str):
293
- # # Step 1: Load the image
294
- # image = Image.open(dir_img)
295
-
296
- # # Step 2: Convert the image to grayscale and normalize
439
+ # Helper functions for common operations
440
+ def overlay_labels(
441
+ image: np.ndarray,
442
+ labels: np.ndarray,
443
+ alpha: float = 0.3,
444
+ plot: bool = False
445
+ ) -> np.ndarray:
446
+ """
447
+ Create overlay of labels on original image
448
+
449
+ Args:
450
+ image: Original image
451
+ labels: Label array
452
+ alpha: Transparency for overlay
453
+ plot: Whether to show result
454
+
455
+ Returns:
456
+ Overlay image
457
+ """
458
+ overlay = color.label2rgb(labels, image=image, alpha=alpha)
459
+ if plot:
460
+ plt.figure(figsize=(10, 5))
461
+ plt.imshow(overlay)
462
+ plt.axis('off')
463
+ plt.show()
464
+ return overlay
465
+
466
+
467
+ def extract_region_features(
468
+ label_image: np.ndarray,
469
+ intensity_image: np.ndarray,
470
+ features: List[str] = None
471
+ ) -> pd.DataFrame:
472
+ """
473
+ Extract specific features from labeled regions
474
+
475
+ Args:
476
+ label_image: Labeled image
477
+ intensity_image: Intensity image for measurements
478
+ features: List of features to extract
479
+
480
+ Returns:
481
+ DataFrame of region features
482
+ """
483
+ default_features = [
484
+ 'area', 'bbox', 'centroid', 'convex_area', 'eccentricity',
485
+ 'equivalent_diameter', 'euler_number', 'extent', 'filled_area',
486
+ 'label', 'major_axis_length', 'max_intensity', 'mean_intensity',
487
+ 'min_intensity', 'minor_axis_length', 'orientation', 'perimeter',
488
+ 'solidity', 'weighted_centroid'
489
+ ]
490
+
491
+ features = features or default_features
492
+ props = measure.regionprops_table(label_image, intensity_image, properties=features)
493
+ return pd.DataFrame(props)
494
+
495
+
496
+
497
+ # import numpy as np
498
+ # import pandas as pd
499
+ # import matplotlib.pyplot as plt
500
+ # from scipy.stats import pearsonr
501
+ # from PIL import Image
502
+ # from skimage import filters, morphology, measure, color
503
+
504
+ # # 用来处理ich图像的初级工具包
505
+
506
+
507
+ # def open_img(dir_img, convert="gray", plot=False):
508
+ # # Step 1: Load the image
509
+ # image = Image.open(dir_img)
510
+
511
+ # if convert == "gray" or convert == "grey":
297
512
  # gray_image = image.convert("L")
298
513
  # image_array = np.array(gray_image)
514
+ # else:
515
+ # image_array = np.array(image)
516
+ # if plot:
517
+ # _, axs = plt.subplots(1, 2)
518
+ # axs[0].imshow(image)
519
+ # axs[1].imshow(image_array)
520
+ # axs[0].set_title("img_raw")
521
+ # axs[1].set_title(f"img_{convert}")
522
+ # return image, image_array
523
+
524
+
525
+ # from skimage import filters, morphology
526
+
527
+
528
+ # def clean_img(
529
+ # img,
530
+ # method=["threshold_otsu", "objects", "holes"],
531
+ # obj_min=50,
532
+ # hole_min=50,
533
+ # filter=None,
534
+ # plot=False,
535
+ # cmap="grey",
536
+ # ):
537
+ # if isinstance(method, str):
538
+ # if method == "all":
539
+ # method = ["threshold_otsu", "objects", "holes"]
540
+ # else:
541
+ # method = [method]
542
+ # if any("thr" in met or "ot" in met for met in method) and filter is None:
543
+ # thr_otsu = filters.threshold_otsu(img)
544
+ # img_update = img > thr_otsu
545
+ # if any("obj" in met for met in method):
546
+ # img_update = morphology.remove_small_objects(img_update, min_size=obj_min)
547
+ # if any("hol" in met for met in method):
548
+ # img_update = morphology.remove_small_holes(img_update, area_threshold=hole_min)
549
+ # if ("thr" in met for met in method) and filter: # threshold
550
+ # mask = (img >= filter[0]) & (img <= filter[1])
551
+ # img_update = np.where(mask, img, 0)
552
+
553
+ # if plot:
554
+ # plt.imshow(img_update, cmap=cmap)
555
+ # return img_update
556
+
557
+
558
+ # from skimage import filters, segmentation
559
+
560
+
561
+ # def segment_img(
562
+ # img,
563
+ # filter=[30, 150],
564
+ # plot=False,
565
+ # mode="reflect", # 'reflect' or 'constant'
566
+ # method="region", # 'region' or 'edge', 'threshold'
567
+ # area_min=50,
568
+ # cmap="jet",
569
+ # connectivity=1,
570
+ # output="segmentation",
571
+ # ):
572
+ # if "reg" in method: # region method
573
+ # # 1. find an elevation map using the Sobel gradient of the image
574
+ # elevation_map = filters.sobel(img, mode=mode)
575
+ # # 2. find markers of the background and the coins based on the extreme parts of the histogram of gray values.
576
+ # markers = np.zeros_like(img)
577
+ # # Apply filtering based on provided filter values
578
+ # if filter is not None:
579
+ # markers[img < filter[0]] = 1
580
+ # markers[img > filter[1]] = 2
581
+ # else:
582
+ # # If no filter is provided, set markers across the whole range of the image
583
+ # markers[img == img.min()] = 1
584
+ # markers[img == img.max()] = 2
585
+ # # 3. watershed transform to fill regions of the elevation map starting from the markers
586
+ # img_segmentation = segmentation.watershed(
587
+ # elevation_map, markers=markers, connectivity=connectivity
588
+ # )
589
+ # if plot:
590
+ # _, axs = plt.subplots(2, 2)
591
+ # for i, ax in enumerate(axs.flatten().tolist()):
592
+ # if i == 0:
593
+ # ax.imshow(img)
594
+ # ax.set_title("image")
595
+ # elif i == 1:
596
+ # ax.imshow(elevation_map, cmap=cmap)
597
+ # ax.set_title("elevation map")
598
+ # elif i == 2:
599
+ # ax.imshow(markers, cmap=cmap)
600
+ # ax.set_title("markers")
601
+ # elif i == 3:
602
+ # ax.imshow(img_segmentation, cmap=cmap)
603
+ # ax.set_title("segmentation")
604
+ # ax.set_axis_off()
605
+ # if "el" in output:
606
+ # return elevation_map
607
+ # elif "mar" in output:
608
+ # return markers
609
+ # elif "seg" in output:
610
+ # return img_segmentation
611
+ # else:
612
+ # return img_segmentation
613
+ # elif "ed" in method: # edge
614
+ # edges = cal_edges(img)
615
+ # fills = fill_holes(edges)
616
+ # img_segmentation = remove_holes(fills, area_min)
617
+ # if plot:
618
+ # _, axs = plt.subplots(2, 2)
619
+ # for i, ax in enumerate(axs.flatten().tolist()):
620
+ # if i == 0:
621
+ # ax.imshow(img)
622
+ # ax.set_title("image")
623
+ # elif i == 1:
624
+ # ax.imshow(edges, cmap=cmap)
625
+ # ax.set_title("edges map")
626
+ # elif i == 2:
627
+ # ax.imshow(fills, cmap=cmap)
628
+ # ax.set_title("fills")
629
+ # elif i == 3:
630
+ # ax.imshow(img_segmentation, cmap=cmap)
631
+ # ax.set_title("segmentation")
632
+ # ax.set_axis_off()
633
+ # if "seg" in output:
634
+ # return img_segmentation
635
+ # elif "ed" in output:
636
+ # return edges
637
+ # elif "fill" in output:
638
+ # return fills
639
+ # else:
640
+ # return img_segmentation
641
+ # elif "thr" in method: # threshold
642
+ # if filter:
643
+ # mask = (img >= filter[0]) & (img <= filter[1])
644
+ # img_threshold = np.where(mask, img, 0)
645
+ # if plot:
646
+ # plt.imshow(img_threshold, cmap=cmap)
647
+ # return img_threshold
648
+ # else:
649
+ # return None
650
+
651
+
652
+ # from skimage import measure
653
+
654
+
655
+ # def label_img(img, plot=False):
656
+ # img_label = measure.label(img)
657
+ # if plot:
658
+ # plt.imshow(img_label)
659
+ # return img_label
660
+
661
+
662
+ # def img_process(img, **kwargs):
663
+ # convert = "gray"
664
+ # method_clean_img = ["threshold_otsu", "objects", "holes"]
665
+ # obj_min_clean_img = 50
666
+ # hole_min_clean_img = 50
667
+ # plot = True
668
+ # for k, v in kwargs.items():
669
+ # if "convert" in k.lower():
670
+ # convert = v
671
+ # if "met" in k.lower() and any(
672
+ # ["clean" in k.lower(), "rem" in k.lower(), "rm" in k.lower()]
673
+ # ):
674
+ # method_clean_img = v
675
+ # if "obj" in k.lower() and any(
676
+ # ["clean" in k.lower(), "rem" in k.lower(), "rm" in k.lower()]
677
+ # ):
678
+ # obj_min_clean_img = v
679
+ # if "hol" in k.lower() and any(
680
+ # ["clean" in k.lower(), "rem" in k.lower(), "rm" in k.lower()]
681
+ # ):
682
+ # hole_min_clean_img = v
683
+ # if "plot" in k.lower():
684
+ # plot = v
685
+
686
+ # if isinstance(img, str):
687
+ # image, image_array = open_img(img, convert=convert)
299
688
  # normalized_image = image_array / 255.0
300
689
  # else:
301
- # cleaned_image = dir_img
690
+ # cleaned_image = img
302
691
  # image_array = cleaned_image
303
692
  # normalized_image = cleaned_image
304
693
  # image = cleaned_image
305
- # binary_image = cleaned_image
306
- # thr_val = None
307
- # if subtract_background:
308
- # # Step 3: Apply thresholding to segment the image
309
- # thr_val = filters.threshold_otsu(image_array)
310
- # print(f"Threshold value is: {thr_val}")
311
-
312
- # # Apply thresholds and generate binary images
313
- # binary_image = image_array > thr_val
314
-
315
- # # Step 4: Perform morphological operations to clean the image
316
- # # Remove small objects and fill small holes
317
- # cleaned_image_rm_min_obj = morphology.remove_small_objects(
318
- # binary_image, min_size=size_obj
319
- # )
320
- # cleaned_image = morphology.remove_small_holes(
321
- # cleaned_image_rm_min_obj, area_threshold=size_hole
322
- # )
323
694
 
695
+ # # Remove small objects and fill small holes
696
+ # cleaned_image = clean_img(
697
+ # img=image_array,
698
+ # method=method_clean_img,
699
+ # obj_min=obj_min_clean_img,
700
+ # hole_min=hole_min_clean_img,
701
+ # plot=False,
702
+ # )
324
703
  # # Label the regions
325
704
  # label_image = label_img(cleaned_image)
326
-
327
- # # Optional: Overlay labels on the original image
328
- # overlay_image = color.label2rgb(label_image, image_array)
705
+ # overlay_image = overlay_imgs(label_image, image=image_array)
329
706
  # regions = measure.regionprops(label_image, intensity_image=image_array)
330
707
  # region_props = measure.regionprops_table(
331
708
  # label_image, intensity_image=image_array, properties=props_list
@@ -336,18 +713,137 @@ def img_process(img, **kwargs):
336
713
  # "img": image,
337
714
  # "img_array": image_array,
338
715
  # "img_scale": normalized_image,
339
- # "img_binary": binary_image,
340
716
  # "img_clean": cleaned_image,
341
717
  # "img_label": label_image,
342
718
  # "img_overlay": overlay_image,
343
- # "thr_val": thr_val,
344
719
  # "regions": regions,
345
720
  # "df_regions": df_regions,
346
721
  # }
347
-
722
+ # if plot:
723
+ # imgs = []
724
+ # [imgs.append(i) for i in list(output.keys()) if "img" in i]
725
+ # for img_ in imgs:
726
+ # plt.figure()
727
+ # plt.imshow(output[img_])
728
+ # plt.title(img_)
348
729
  # return output
349
730
 
350
731
 
732
+ # # def img_preprocess(dir_img, subtract_background=True, size_obj=50, size_hole=50,**kwargs):
733
+ # # """
734
+ # # Processes an image by performing thresholding, morphological operations,
735
+ # # and region labeling.
736
+
737
+ # # Parameters:
738
+ # # - dir_img: Path to the image file.
739
+ # # - size_obj: Minimum size of objects to keep (default: 50).
740
+ # # - size_hole: Maximum size of holes to fill (default: 50).
741
+
742
+ # # Returns:
743
+ # # - output: Dictionary containing the overlay image, threshold value, and regions.
744
+ # # """
745
+ # # props_list = [
746
+ # # "area", # Number of pixels in the region. Useful for determining the size of regions.
747
+ # # "area_bbox",
748
+ # # "area_convex",
749
+ # # "area_filled",
750
+ # # "axis_major_length", # Lengths of the major and minor axes of the ellipse that fits the region. Useful for understanding the shape's elongation and orientation.
751
+ # # "axis_minor_length",
752
+ # # "bbox", # Bounding box coordinates (min_row, min_col, max_row, max_col). Useful for spatial localization of regions.
753
+ # # "centroid", # Center of mass coordinates (centroid-0, centroid-1). Helps locate the center of each region.
754
+ # # "centroid_local",
755
+ # # "centroid_weighted",
756
+ # # "centroid_weighted_local",
757
+ # # "coords",
758
+ # # "eccentricity", # Measure of how elongated the region is. Values range from 0 (circular) to 1 (line). Useful for assessing the shape of regions.
759
+ # # "equivalent_diameter_area", # Diameter of a circle with the same area as the region. Provides a simple measure of size.
760
+ # # "euler_number",
761
+ # # "extent", # Ratio of the region's area to the area of its bounding box. Indicates how much of the bounding box is filled by the region.
762
+ # # "feret_diameter_max", # Maximum diameter of the region, providing another measure of size.
763
+ # # "image",
764
+ # # "image_convex",
765
+ # # "image_filled",
766
+ # # "image_intensity",
767
+ # # "inertia_tensor", # ensor describing the distribution of mass in the region, useful for more advanced shape analysis.
768
+ # # "inertia_tensor_eigvals",
769
+ # # "intensity_max", # Maximum intensity value within the region. Helps identify regions with high-intensity features.
770
+ # # "intensity_mean", # Average intensity value within the region. Useful for distinguishing between regions based on their brightness.
771
+ # # "intensity_min", # Minimum intensity value within the region. Useful for regions with varying intensity.
772
+ # # "intensity_std",
773
+ # # "label", # Unique identifier for each region.
774
+ # # "moments",
775
+ # # "moments_central",
776
+ # # "moments_hu", # Hu moments are a set of seven invariant features that describe the shape of the region. Useful for shape recognition and classification.
777
+ # # "moments_normalized",
778
+ # # "moments_weighted",
779
+ # # "moments_weighted_central",
780
+ # # "moments_weighted_hu",
781
+ # # "moments_weighted_normalized",
782
+ # # "orientation", # ngle of the major axis of the ellipse that fits the region. Useful for determining the orientation of elongated regions.
783
+ # # "perimeter", # Length of the boundary of the region. Useful for shape analysis.
784
+ # # "perimeter_crofton",
785
+ # # "slice",
786
+ # # "solidity", # Ratio of the area of the region to the area of its convex hull. Indicates how solid or compact a region is.
787
+ # # ]
788
+ # # if isinstance(dir_img, str):
789
+ # # # Step 1: Load the image
790
+ # # image = Image.open(dir_img)
791
+
792
+ # # # Step 2: Convert the image to grayscale and normalize
793
+ # # gray_image = image.convert("L")
794
+ # # image_array = np.array(gray_image)
795
+ # # normalized_image = image_array / 255.0
796
+ # # else:
797
+ # # cleaned_image = dir_img
798
+ # # image_array = cleaned_image
799
+ # # normalized_image = cleaned_image
800
+ # # image = cleaned_image
801
+ # # binary_image = cleaned_image
802
+ # # thr_val = None
803
+ # # if subtract_background:
804
+ # # # Step 3: Apply thresholding to segment the image
805
+ # # thr_val = filters.threshold_otsu(image_array)
806
+ # # print(f"Threshold value is: {thr_val}")
807
+
808
+ # # # Apply thresholds and generate binary images
809
+ # # binary_image = image_array > thr_val
810
+
811
+ # # # Step 4: Perform morphological operations to clean the image
812
+ # # # Remove small objects and fill small holes
813
+ # # cleaned_image_rm_min_obj = morphology.remove_small_objects(
814
+ # # binary_image, min_size=size_obj
815
+ # # )
816
+ # # cleaned_image = morphology.remove_small_holes(
817
+ # # cleaned_image_rm_min_obj, area_threshold=size_hole
818
+ # # )
819
+
820
+ # # # Label the regions
821
+ # # label_image = label_img(cleaned_image)
822
+
823
+ # # # Optional: Overlay labels on the original image
824
+ # # overlay_image = color.label2rgb(label_image, image_array)
825
+ # # regions = measure.regionprops(label_image, intensity_image=image_array)
826
+ # # region_props = measure.regionprops_table(
827
+ # # label_image, intensity_image=image_array, properties=props_list
828
+ # # )
829
+ # # df_regions = pd.DataFrame(region_props)
830
+ # # # Pack the results into a single output variable (dictionary)
831
+ # # output = {
832
+ # # "img": image,
833
+ # # "img_array": image_array,
834
+ # # "img_scale": normalized_image,
835
+ # # "img_binary": binary_image,
836
+ # # "img_clean": cleaned_image,
837
+ # # "img_label": label_image,
838
+ # # "img_overlay": overlay_image,
839
+ # # "thr_val": thr_val,
840
+ # # "regions": regions,
841
+ # # "df_regions": df_regions,
842
+ # # }
843
+
844
+ # # return output
845
+
846
+
351
847
  def cal_pearson(img1, img2):
352
848
  """Compute Pearson correlation coefficient between two images."""
353
849
  img1_flat = img1.flatten()
@@ -483,13 +979,21 @@ def draw_bbox(
483
979
  """
484
980
  if ax is None:
485
981
  ax = plt.gca()
486
- if img_label is None:
487
- img_label = measure.label(img)
982
+ try:
983
+ if img_label is None:
984
+ img_label = measure.label(img)
985
+ except Exception as e:
986
+ print(e)
987
+ img_label=img
488
988
  if isinstance(show, bool):
489
989
  if show:
490
- if img_label2rgb is None:
491
- img_label2rgb = color.label2rgb(img_label, image=img, bg_label=0)
492
- ax.imshow(img_label2rgb, alpha=bg_alpha)
990
+ try:
991
+ if img_label2rgb is None:
992
+ img_label2rgb = color.label2rgb(img_label, image=img, bg_label=0)
993
+ ax.imshow(img_label2rgb, alpha=bg_alpha)
994
+ except Exception as e:
995
+ print(e)
996
+ ax.imshow(img_label, alpha=bg_alpha)
493
997
  elif isinstance(show, str):
494
998
  if "raw" in show:
495
999
  ax.imshow(img, alpha=bg_alpha)
@@ -587,4 +1091,1159 @@ props_list = [
587
1091
  "perimeter_crofton",
588
1092
  "slice",
589
1093
  "solidity", # Ratio of the area of the region to the area of its convex hull. Indicates how solid or compact a region is.
590
- ]
1094
+ ]
1095
+
1096
+ from skimage.util import img_as_ubyte
1097
+
1098
+ def remove_high_intensity_artifacts(img, threshold=200, replace_value=255, min_size=1):
1099
+ """
1100
+ Remove high-intensity artifacts from image.
1101
+
1102
+ Parameters:
1103
+ - img: Input image (2D or 3D numpy array)
1104
+ - threshold: Intensity threshold (typically 230-255)
1105
+ - replace_value: Value or color to replace artifacts with
1106
+ - min_size: Minimum artifact size to remove (in pixels)
1107
+
1108
+ Returns:
1109
+ - cleaned: Image with artifacts removed/replaced
1110
+ """
1111
+ # Replace NaNs with 0 before type conversion
1112
+ if np.isnan(img).any():
1113
+ img = np.nan_to_num(img, nan=0)
1114
+
1115
+ try:
1116
+ img = img_as_ubyte(img)
1117
+ except Exception as e:
1118
+ print(f"Failed to use img_as_ubyte: {e}")
1119
+
1120
+ if img.ndim not in [2, 3]:
1121
+ raise ValueError("Input image must be 2D (grayscale) or 3D (RGB).")
1122
+
1123
+ # Create mask for high-intensity regions
1124
+ mask = np.any(img > threshold, axis=-1) if img.ndim == 3 else img > threshold
1125
+ mask = morphology.remove_small_objects(mask, min_size=min_size)
1126
+ mask = morphology.binary_opening(mask, morphology.disk(3))
1127
+
1128
+ cleaned = img.copy()
1129
+
1130
+ if img.ndim == 3:
1131
+ for c in range(3):
1132
+ channel = cleaned[:, :, c]
1133
+ valid = ~mask
1134
+ if np.any(valid):
1135
+ background_val = np.median(channel[valid])
1136
+ else:
1137
+ background_val = 0
1138
+ channel[mask] = background_val
1139
+ cleaned[:, :, c] = channel
1140
+ else:
1141
+ valid = ~mask
1142
+ if np.any(valid):
1143
+ cleaned[mask] = np.median(cleaned[valid])
1144
+ else:
1145
+ cleaned[mask] = replace_value
1146
+
1147
+ return cleaned
1148
+
1149
+ from PIL import Image
1150
+ import numpy as np
1151
+ from skimage.restoration import rolling_ball
1152
+ from skimage import morphology, filters
1153
+ from skimage.util import img_as_ubyte
1154
+ from skimage import img_as_float
1155
+ from scipy.ndimage import median_filter
1156
+ from skimage.morphology import disk
1157
+
1158
+ # Default method-specific config
1159
+ METHOD_CONFIG = {
1160
+ 'rolling_ball': {'radius': 30},
1161
+ 'gaussian_sub': {'sigma': 10},
1162
+ 'median': {'size': 20},
1163
+ 'tophat': {'radius': 15},
1164
+ }
1165
+
1166
+ # Thresholding defaults
1167
+ DEFAULT_THRESHOLD = {
1168
+ 'otsu': True, # could be expanded later for other methods
1169
+ }
1170
+
1171
+ def clean_background(
1172
+ img,
1173
+ method='rolling_ball',
1174
+ thr=None, # custom threshold value, None = use method (e.g., 'otsu')
1175
+ **kwargs # extra config like radius=30, sigma=5, etc.
1176
+ ):
1177
+ """
1178
+ Preprocess an image with background subtraction and thresholding.
1179
+
1180
+ Parameters:
1181
+ - img (str | PIL.Image.Image | np.ndarray): Input image.
1182
+ - method (str): Background subtraction method.
1183
+ - thr (float or None): Threshold value. If None, use default method (e.g., 'otsu').
1184
+ - kwargs: Method-specific overrides like radius=30, sigma=5, etc.
1185
+
1186
+ Returns:
1187
+ - cleaned (ndarray): Binary preprocessed image (uint8).
1188
+ """
1189
+
1190
+ # Step 1: Load and normalize input image
1191
+ if isinstance(img, str):
1192
+ img_pil = Image.open(img).convert('L')
1193
+ elif isinstance(img, Image.Image):
1194
+ img_pil = img.convert('L')
1195
+ elif isinstance(img, np.ndarray):
1196
+ img_pil = Image.fromarray(img).convert('L') if img.ndim == 3 else Image.fromarray(img.astype(np.uint8))
1197
+ else:
1198
+ raise TypeError("Input must be a file path (str), PIL.Image, or numpy.ndarray")
1199
+
1200
+ img_gray = np.array(img_pil)
1201
+ img_float = img_as_float(img_gray)
1202
+
1203
+ # Step 2: Load defaults and override with kwargs
1204
+ config = METHOD_CONFIG.get(method)
1205
+ if config is None:
1206
+ raise ValueError(f"Unknown method: {method}")
1207
+ config = config.copy()
1208
+ config.update({k: v for k, v in kwargs.items() if v is not None}) # override if provided
1209
+
1210
+ # Step 3: Apply background subtraction
1211
+ if method == 'rolling_ball':
1212
+ img_bg_removed = rolling_ball(img_float, radius=config['radius'])
1213
+ elif method == 'gaussian_sub':
1214
+ img_bg_removed = img_float - filters.gaussian(img_float, sigma=config['sigma'])
1215
+ elif method == 'median':
1216
+ img_bg_removed = img_float - median_filter(img_float, size=config['size'])
1217
+ elif method == 'tophat':
1218
+ selem = disk(config['radius'])
1219
+ img_bg_removed = morphology.white_tophat(img_gray, selem=selem)
1220
+ img_bg_removed = img_as_float(img_bg_removed)
1221
+ else:
1222
+ raise ValueError(f"Unsupported method: {method}")
1223
+
1224
+ # # Step 4: Thresholding
1225
+ # if thr is None:
1226
+ # # use Otsu
1227
+ # thr_val = filters.threshold_otsu(img_bg_removed)
1228
+ # else:
1229
+ # thr_val = thr
1230
+
1231
+ # binary = img_bg_removed > thr_val
1232
+
1233
+ # # Step 5: Morphological cleanup
1234
+ # cleaned = morphology.remove_small_objects(binary, min_size=100)
1235
+ # cleaned = morphology.remove_small_holes(cleaned, area_threshold=100)
1236
+
1237
+ return img_as_ubyte(img_bg_removed)
1238
+
1239
+
1240
+ #! ============增强版的图像分析工具===============
1241
+ # 支持多种染色方法并自动判断是否需要反转通道。该工具可以处理HED、RGB、荧光等多种染色方式,并支持多通道联合分析。
1242
+ # 多通道联合分析:可以分析两个染色通道的共定位情况,如DAB阳性细胞核
1243
+ # 自动通道反转:根据染色类型自动决定是否需要反转通道值
1244
+ # 标准化输出:所有结果都包含统一的测量指标,便于比较
1245
+ # 灵活的可视化:支持叠加显示和单独显示各通道结果
1246
+
1247
+ from PIL import Image
1248
+ import numpy as np
1249
+ import matplotlib.pyplot as plt
1250
+ from matplotlib.colors import LinearSegmentedColormap, ListedColormap
1251
+ import pandas as pd
1252
+ from skimage import morphology,measure,color,filters
1253
+ from sklearn import cluster
1254
+ from scipy.ndimage import median_filter
1255
+ from skimage import exposure
1256
+ import matplotlib.colors as mcolors
1257
+ from skimage.color import rgb2hsv
1258
+ class StainDetector:
1259
+ """
1260
+ 多功能染色分析工具,支持多种染色方法
1261
+
1262
+ 支持的染色方法:
1263
+ - HED染色: 核染色(Hematoxylin), 伊红(Eosin), DAB
1264
+ - RGB染色: 常规RGB通道分析
1265
+ - 荧光染色: FITC(绿), TRITC(红), DAPI(蓝), Cy5(远红)
1266
+ - 特殊染色: Masson(胶原纤维), PAS(糖原), Silver(神经纤维)
1267
+ """
1268
+ ARTIFACT_STRATEGIES = {
1269
+ 'median': lambda orig, mask: median_filter(orig, size=(5,5,1)),
1270
+ 'inpaint': lambda orig, mask: cv2.inpaint(orig, mask.astype(np.uint8), 3, cv2.INPAINT_TELEA),
1271
+ 'background': lambda orig, mask: np.where(mask[...,None], np.percentile(orig, 25, axis=(0,1)), orig),
1272
+ 'blur': lambda orig, mask: np.where(mask[...,None], filters.gaussian(orig, sigma=2), orig)
1273
+ }
1274
+ # 染色方法配置字典
1275
+ STAIN_CONFIG = {
1276
+ # HED染色
1277
+ "hematoxylin": {
1278
+ "method": "hed",
1279
+ "channel": 0,
1280
+ "invert": True,
1281
+ "color": "hematoxylin", # Special name for custom cmap
1282
+ "alpha": 0.5,
1283
+ "cmap": None, # Will be created dynamically
1284
+ },
1285
+ "nuclei": {
1286
+ "method": "hed",
1287
+ "channel": 0,
1288
+ "invert": True,
1289
+ "color": "hematoxylin", # Special name for custom cmap
1290
+ "alpha": 0.5,
1291
+ "cmap": None, # Will be created dynamically
1292
+ },
1293
+ "purple": {
1294
+ "method": "hed",
1295
+ "channel": 0,
1296
+ "invert": True,
1297
+ "color": "hematoxylin", # Special name for custom cmap
1298
+ "alpha": 0.5,
1299
+ "cmap": None, # Will be created dynamically
1300
+ },
1301
+ "h": {
1302
+ "method": "hed",
1303
+ "channel": 0,
1304
+ "invert": True,
1305
+ "color": "hematoxylin", # Special name for custom cmap
1306
+ "alpha": 0.5,
1307
+ "cmap": None, # Will be created dynamically
1308
+ },
1309
+ "eosin": {
1310
+ "method": "hed",
1311
+ "channel": 1,
1312
+ "invert": False,
1313
+ "color": "eosin", # Special name for custom cmap
1314
+ "alpha": 0.45, # Slightly more visible than H
1315
+ "cmap": None,
1316
+ },
1317
+ "cytoplasm": {
1318
+ "method": "hed",
1319
+ "channel": 1,
1320
+ "invert": False,
1321
+ "color": "eosin", # Special name for custom cmap
1322
+ "alpha": 0.45, # Slightly more visible than H
1323
+ "cmap": None,
1324
+ },
1325
+ "pink": {
1326
+ "method": "hed",
1327
+ "channel": 1,
1328
+ "invert": False,
1329
+ "color": "eosin", # Special name for custom cmap
1330
+ "alpha": 0.45, # Slightly more visible than H
1331
+ "cmap": None,
1332
+ },
1333
+ "e": {
1334
+ "method": "hed",
1335
+ "channel": 1,
1336
+ "invert": False,
1337
+ "color": "eosin", # Special name for custom cmap
1338
+ "alpha": 0.45, # Slightly more visible than H
1339
+ "cmap": None,
1340
+ },
1341
+ "dab": {
1342
+ "method": "hed",
1343
+ "channel": 2,
1344
+ "invert": True,
1345
+ "color": "dab",
1346
+ "alpha": 0.4,
1347
+ "cmap": None,
1348
+ },
1349
+ # RGB单通道
1350
+ "red": {"method": "rgb", "channel": "R", "invert": False, "color": "red"},
1351
+ "green": {"method": "rgb", "channel": "G", "invert": False, "color": "green"},
1352
+ "blue": {"method": "rgb", "channel": "B", "invert": False, "color": "blue"},
1353
+ # 荧光染色
1354
+ "dapi": {
1355
+ "method": "rgb",
1356
+ "channel": "B",
1357
+ "invert": True,
1358
+ "color": "dapi",
1359
+ "alpha": 0.5,
1360
+ "cmap": None,
1361
+ },
1362
+ "fitc": {
1363
+ "method": "rgb",
1364
+ "channel": "G",
1365
+ "invert": False,
1366
+ "color": "fitc",
1367
+ "alpha": 0.6,
1368
+ "cmap": None,
1369
+ },
1370
+ "tritc": {"method": "rgb", "channel": "R", "invert": False, "color": "red"},
1371
+ "cy5": {"method": "hsv", "channel": 0, "invert": True, "color": "magenta"},
1372
+ # 特殊染色
1373
+ "masson": {"method": "rgb", "channel": "B", "invert": False, "color": "blue"},
1374
+ "pas": {"method": "rgb", "channel": "R", "invert": False, "color": "magenta"},
1375
+ "silver": {"method": "rgb", "channel": "G", "invert": True, "color": "black"},
1376
+ }
1377
+
1378
+ def __init__(self, image_path):
1379
+ """初始化分析器,加载图像"""
1380
+ self.results = {}
1381
+ self._init_custom_colormaps()
1382
+ if isinstance(image_path,str):
1383
+ self.image_path = image_path
1384
+ self.image_rgb = np.array(Image.open(image_path).convert("RGB"))
1385
+ else:
1386
+ self.image_path=None
1387
+ self.image_rgb=image_path
1388
+ # Precompute HSV for color-based analysis
1389
+ self.image_hsv = rgb2hsv(self.image_rgb / 255.0) # Normalized HSV
1390
+
1391
+ def extract_channel(self, stain_type, channel='rgb'):
1392
+ """
1393
+ 根据染色类型提取对应通道,支持自定义通道或自动检测
1394
+
1395
+ 参数:
1396
+ stain_type: 染色类型名称 (必须存在于STAIN_CONFIG中)
1397
+ channel: 可选,可覆盖默认通道设置:
1398
+ - 对于HED: 0,1,2
1399
+ - 对于RGB: 'R','G','B'
1400
+ - 对于HSV: 0,1,2
1401
+ """
1402
+ config = self.STAIN_CONFIG[stain_type.lower()]
1403
+ method = config["method"]
1404
+
1405
+ # 优先使用自定义通道
1406
+ if channel is not None:
1407
+ if method == "rgb" and isinstance(channel, int):
1408
+ channel = ['R','G','B'][channel]
1409
+ config = config.copy() # 避免修改原始配置
1410
+ config["channel"] = channel
1411
+
1412
+ if method == "hed":
1413
+ stains = color.rgb2hed(self.image_rgb)
1414
+ channel = stains[:, :, config["channel"]]
1415
+ elif method == "rgb":
1416
+ idx = {"R": 0, "G": 1, "B": 2}[str(config["channel"]).upper()]
1417
+ channel = self.image_rgb[:, :, idx]
1418
+ elif method == "hsv":
1419
+ hsv = color.rgb2hsv(self.image_rgb)
1420
+ channel = hsv[:, :, config["channel"]]
1421
+
1422
+ return channel, config
1423
+
1424
+ def detect_primary_channel(self, method="hed"):
1425
+ """
1426
+ 自动检测图像中信号最强的通道
1427
+
1428
+ 参数:
1429
+ method: 检测模式 ('hed', 'rgb', 或 'hsv')
1430
+ 返回:
1431
+ 通道索引 (对于HED/HSV为0-2,对于RGB为'R'/'G'/'B')
1432
+ """
1433
+ if method == "hed":
1434
+ stains = color.rgb2hed(self.image_rgb)
1435
+ channel_means = [np.mean(stains[:,:,i]) for i in range(3)]
1436
+ return np.argmax(channel_means)
1437
+ elif method == "rgb":
1438
+ channel_means = [np.mean(self.image_rgb[:,:,i]) for i in range(3)]
1439
+ return ['R','G','B'][np.argmax(channel_means)]
1440
+ elif method == "hsv":
1441
+ hsv = color.rgb2hsv(self.image_rgb)
1442
+ channel_means = [np.mean(hsv[:,:,i]) for i in range(3)]
1443
+ return np.argmax(channel_means)
1444
+ else:
1445
+ raise ValueError("Method must be 'hed', 'rgb', or 'hsv'")
1446
+
1447
+ def process(self,
1448
+ stain_type,
1449
+ sigma=1.0,
1450
+ min_size=50,
1451
+ hole_size=100,
1452
+ channel=None,
1453
+ subtract_background=False,
1454
+ use_local_threshold=False,
1455
+ apply_remove_high_intensity_artifacts=False,
1456
+ block_size=35, #use_local_threshold
1457
+ contrast=False,
1458
+ contrast_method="clahe",
1459
+ clip_limit=0.01,# only for clahe method
1460
+ stretch_range=(2,98),# only for stretch method
1461
+ ):
1462
+ """
1463
+ 处理特定染色通道
1464
+
1465
+ 参数:
1466
+ stain_type: 染色类型 (如 'dab', 'hematoxylin', 'fitc'等)
1467
+ sigma: 高斯模糊半径
1468
+ min_size: 最小区域像素大小
1469
+
1470
+ 返回:
1471
+ binary_mask, labeled_image, dataframe
1472
+ 增强版处理函数,支持自定义通道
1473
+
1474
+ 参数:
1475
+ channel: 可覆盖STAIN_CONFIG中的通道设置
1476
+ """
1477
+ # 自动检测通道(如果未提供且配置允许)
1478
+ if channel is None and self.STAIN_CONFIG[stain_type.lower()].get("auto_detect", True):
1479
+ method = self.STAIN_CONFIG[stain_type.lower()]["method"]
1480
+ channel = self.detect_primary_channel(method)
1481
+ print(f"Auto-detected primary channel for {stain_type}: {channel}")
1482
+
1483
+ channel, config = self.extract_channel(stain_type, channel)
1484
+ # Optional contrast enhancement
1485
+ if contrast:
1486
+ channel = self.enhance_contrast(channel, method=contrast_method,clip_limit=clip_limit,stretch_range=stretch_range)
1487
+ print("increase contrast")
1488
+
1489
+ processed = -channel if config["invert"] else channel
1490
+ if subtract_background:
1491
+ processed=clean_background(processed)
1492
+ if sigma:
1493
+ processed = filters.gaussian(processed, sigma=sigma)
1494
+ if apply_remove_high_intensity_artifacts:
1495
+ processed=remove_high_intensity_artifacts(processed, threshold=250, replace_value=255, min_size=20)
1496
+ processed = (processed - processed.min()) / (processed.max() - processed.min())
1497
+ # blurred = blurred[~np.isnan(blurred)]
1498
+ if np.isnan(processed).any():
1499
+ processed = np.nan_to_num(processed, nan=0)
1500
+ # --- 阈值处理部分 ---
1501
+ if use_local_threshold:
1502
+ threshold = filters.threshold_local(processed, block_size)
1503
+ else:
1504
+ threshold = filters.threshold_otsu(processed)
1505
+ binary_mask = processed > threshold
1506
+
1507
+ # Remove small objects and fill holes
1508
+ cleaned_mask = morphology.remove_small_objects(binary_mask, min_size=min_size)
1509
+ try:
1510
+ cleaned_mask = morphology.remove_small_holes(cleaned_mask, area_threshold=hole_size)
1511
+ cleaned_mask = morphology.binary_closing(cleaned_mask, morphology.disk(2))
1512
+ except Exception as e:
1513
+ print(f"cannot fill the holes but only removed small objects, error: {e}")
1514
+
1515
+ labeled = morphology.label(cleaned_mask)
1516
+
1517
+ # 提取区域属性
1518
+ props = measure.regionprops_table(
1519
+ labeled,
1520
+ intensity_image=channel,# use the original image instead of processed,
1521
+ properties=[
1522
+ "area",
1523
+ "mean_intensity",
1524
+ "eccentricity",
1525
+ "solidity",
1526
+ "bbox",
1527
+ "centroid",
1528
+ "label"
1529
+ ],
1530
+ )
1531
+ df = pd.DataFrame(props)
1532
+ df["stain_type"] = stain_type
1533
+
1534
+ # 存储结果
1535
+ self.results[stain_type] = {
1536
+ "mask": cleaned_mask,
1537
+ "labeled": labeled,
1538
+ "df": df,
1539
+ "color": config["color"],
1540
+ }
1541
+
1542
+ return cleaned_mask, labeled, df
1543
+
1544
+
1545
+
1546
+ def enhance_contrast(self, channel_array, method='clahe', clip_limit=0.5,stretch_range=(2,98)):
1547
+ """
1548
+ Enhance contrast for a specific channel.
1549
+
1550
+ Parameters:
1551
+ channel_array: 2D numpy array of the selected channel.
1552
+ method: 'clahe' (default), or 'stretch'
1553
+ clip_limit: Only for CLAHE, controls contrast enhancement strength.
1554
+
1555
+ Returns:
1556
+ contrast_enhanced_channel: 2D numpy array after enhancement
1557
+ """
1558
+ if method == 'clahe':
1559
+ # Apply CLAHE (adaptive histogram equalization)
1560
+ enhanced = exposure.equalize_adapthist(channel_array, clip_limit=clip_limit)
1561
+ elif method == 'stretch':
1562
+ # Contrast stretching
1563
+
1564
+ lo, hi = stretch_range
1565
+ lo = max(0, min(lo, 100))
1566
+ hi = max(0, min(hi, 100))
1567
+ p2, p98 = np.percentile(channel_array, (lo,hi))
1568
+ enhanced = exposure.rescale_intensity(channel_array, in_range=(p2, p98))
1569
+ else:
1570
+ raise ValueError("Unsupported method. Use 'clahe' or 'stretch'.")
1571
+
1572
+ return enhanced
1573
+
1574
+ def remove_artifacts(self,
1575
+ intensity_range=(230, 255),
1576
+ strategy='median',
1577
+ min_artifact_size=1,
1578
+ opening_radius=3,
1579
+ dilation_radius=2):
1580
+ """
1581
+ Remove high-intensity artifacts from the image using specified strategy
1582
+
1583
+ Parameters:
1584
+ - intensity_range: Tuple (min, max) intensity values to consider as artifacts
1585
+ - strategy: Artifact replacement strategy (median/inpaint/background/blur)
1586
+ - min_artifact_size: Minimum contiguous artifact area to remove (pixels)
1587
+ - opening_radius: Morphological opening disk radius for mask cleaning
1588
+ - dilation_radius: Morphological dilation disk radius for mask expansion
1589
+ """
1590
+ # # Create base artifact mask
1591
+ try:
1592
+ if isinstance(self.image_rgb,np.array):
1593
+ self.image_rgb=np.array(self.image_rgb)
1594
+ except:
1595
+ pass
1596
+ mask = np.any((self.image_rgb >= intensity_range[0]) &
1597
+ (self.image_rgb <= intensity_range[1]), axis=2)
1598
+
1599
+ # Refine artifact mask
1600
+ mask = morphology.binary_opening(mask, morphology.disk(opening_radius))
1601
+ mask = morphology.remove_small_objects(mask, min_artifact_size)
1602
+ mask = morphology.binary_dilation(mask, morphology.disk(dilation_radius))
1603
+
1604
+ # Apply selected replacement strategy
1605
+ if strategy in self.ARTIFACT_STRATEGIES:
1606
+ cleaned = self.image_rgb.copy()
1607
+ replacement = self.ARTIFACT_STRATEGIES[strategy](self.image_rgb, mask)
1608
+ cleaned[mask] = replacement[mask]
1609
+ self.image_rgb = cleaned
1610
+ else:
1611
+ raise ValueError(f"Invalid strategy: {strategy}. Choose from {list(self.ARTIFACT_STRATEGIES.keys())}")
1612
+
1613
+ return mask
1614
+
1615
+ # [Keep existing methods unchanged]
1616
+
1617
+ def enhanced_process(self,
1618
+ stain_type,
1619
+ artifact_params=None,
1620
+ **process_kwargs):
1621
+ """
1622
+ Enhanced processing with optional artifact removal
1623
+
1624
+ Parameters:
1625
+ - artifact_params: Dict of parameters for remove_artifacts()
1626
+ None skips artifact removal
1627
+ - process_kwargs: Arguments for original process() method
1628
+ """
1629
+ if artifact_params:
1630
+ self.remove_artifacts(**artifact_params)
1631
+
1632
+ return self.process(stain_type, **process_kwargs)
1633
+ def estimate_n_cells(self, stain_type=None, eps=50, min_samples=3, verbose=False, ax=None):
1634
+ """
1635
+ Estimate the number of distinct cells by clustering nearby nuclei regions.
1636
+
1637
+ Parameters:
1638
+ - stain_type: Which stain to analyze (defaults to first processed stain)
1639
+ - eps: Maximum distance between points in the same cluster (in pixels)
1640
+ - min_samples: Minimum points to form a dense region
1641
+ - verbose: Whether to print debug information
1642
+ - ax: Matplotlib axis to plot the clusters (optional)
1643
+
1644
+ Returns:
1645
+ - n_cells: Estimated number of distinct cells
1646
+ - df: DataFrame with added 'cluster_id' column (-1 means noise)
1647
+ """
1648
+ if stain_type is None:
1649
+ stain_type = next(iter(self.results.keys()))
1650
+ print(f"'stain_type' not provided, using the first processed stain: {stain_type}")
1651
+
1652
+ # Get the dataframe for this stain
1653
+ df = self.results[stain_type.lower()]["df"].copy()
1654
+
1655
+ # Skip if no regions detected
1656
+ if len(df) == 0:
1657
+ if verbose:
1658
+ print("No regions detected - returning 0 cells")
1659
+ return 0, df
1660
+
1661
+ # Extract centroid coordinates
1662
+ X = df[["centroid-1", "centroid-0"]].values # Using (x,y) format
1663
+
1664
+ # Apply DBSCAN clustering
1665
+ clustering = cluster.DBSCAN(eps=eps, min_samples=min_samples).fit(X)
1666
+ df["cluster_id"] = clustering.labels_
1667
+
1668
+ # Count clusters (ignoring noise points labeled -1)
1669
+ unique_clusters = set(clustering.labels_)
1670
+ n_cells = len(unique_clusters) - (1 if -1 in unique_clusters else 0)
1671
+
1672
+ if verbose:
1673
+ print(f"Estimated number of cells after clustering: {n_cells}")
1674
+
1675
+ # Visualization if requested
1676
+ if ax is not None:
1677
+ ax.scatter(
1678
+ X[:, 0], # x coordinates
1679
+ X[:, 1], # y coordinates
1680
+ c=df["cluster_id"],
1681
+ cmap="tab20",
1682
+ s=30,
1683
+ alpha=0.7
1684
+ )
1685
+ ax.set_title(f"{stain_type} clusters (n={n_cells})")
1686
+ ax.invert_yaxis() # Match image coordinates
1687
+ ax.set_aspect('equal')
1688
+
1689
+ # Store results back in the detector
1690
+ self.results[stain_type.lower()]["df"] = df
1691
+ self.results[stain_type.lower()]["n_cells"] = n_cells
1692
+
1693
+ return n_cells, df
1694
+
1695
+
1696
+ def count_cells(self, stain_type=None, area_min=20, area_max=2000, min_solidity=0.3):
1697
+ """
1698
+ Count cells based on filtered nuclei properties.
1699
+ """
1700
+ if stain_type is None:
1701
+ stain_type = list(self.results.keys())[0]
1702
+ print(f"'stain_type' not provided, using the first processed stain: {stain_type}")
1703
+ df = self.results[stain_type.lower()]["df"].copy()
1704
+
1705
+ # Filter likely nuclei
1706
+ filtered = df[
1707
+ (df["area"] >= area_min) &
1708
+ (df["area"] <= area_max) &
1709
+ (df["solidity"] >= min_solidity)
1710
+ ].copy()
1711
+ self.count_cells_filtered = filtered
1712
+ count = len(filtered)
1713
+ print(f"Detected {count} cells in stain '{stain_type}'")
1714
+ return count
1715
+
1716
+ def calculate_stain_area_ratio(
1717
+ self, stain1=None, stain2=None, verbose=True
1718
+ ):
1719
+ """
1720
+ Calculate area ratio between two stains with proper unit handling
1721
+
1722
+ Args:
1723
+ stain1: First stain (default: hematoxylin/nuclear)
1724
+ stain2: Second stain (default: eosin/cytoplasm)
1725
+ verbose: Print formatted results
1726
+
1727
+ Returns:
1728
+ Dictionary containing:
1729
+ - stain1_area: Area in pixels
1730
+ - stain2_area: Area in pixels
1731
+ - ratio: stain1/stain2
1732
+ - stain1: Name of first stain
1733
+ - stain2: Name of second stain
1734
+ - unit: Always 'pixels' (px²)
1735
+ """
1736
+ # Get results if not already processed
1737
+ if stain1 is None:
1738
+ stain1 = list(self.results.keys())[0]
1739
+ print(f"'stain1' not provided, using the first processed stain: {stain1}")
1740
+ if stain2 is None:
1741
+ stain2 = list(self.results.keys())[1]
1742
+ print(f"'stain2' not provided, using the first processed stain: {stain2}")
1743
+ df1 = self.results[stain1.lower()]["df"]
1744
+ df2 = self.results[stain2.lower()]["df"]
1745
+
1746
+ # Calculate areas
1747
+ area1 = df1["area"].sum()
1748
+ area2 = df2["area"].sum()
1749
+
1750
+ # Handle edge cases
1751
+ if area2 == 0:
1752
+ ratio = float("inf") if area1 > 0 else 0.0
1753
+ else:
1754
+ ratio = area1 / area2
1755
+
1756
+ # Build results
1757
+ result = {
1758
+ "stain1_area": area1,
1759
+ "stain2_area": area2,
1760
+ "ratio": ratio,
1761
+ "stain1": stain1,
1762
+ "stain2": stain2,
1763
+ "unit": "pixels",
1764
+ }
1765
+
1766
+ if verbose:
1767
+ print(
1768
+ f"\nStain Analysis Ratio ({stain1}/{stain2}):\n"
1769
+ f"- {stain1} area: {area1:,} px\n"
1770
+ f"- {stain2} area: {area2:,} px\n"
1771
+ f"- Ratio: {ratio:.4f}"
1772
+ )
1773
+
1774
+ return result
1775
+
1776
+ def analyze_dual_stain(self, stain1, stain2, min_size=50):
1777
+ """
1778
+ 双染色联合分析 (如DAB和核染色)
1779
+
1780
+ 返回:
1781
+ co_localized_mask, co_localized_df
1782
+ """
1783
+ # 确保两个染色已处理
1784
+ if stain1 not in self.results:
1785
+ self.process(stain1)
1786
+ if stain2 not in self.results:
1787
+ self.process(stain2)
1788
+
1789
+ # 获取两个染色的标记图像
1790
+ label1 = self.results[stain1]["labeled"]
1791
+ label2 = self.results[stain2]["labeled"]
1792
+
1793
+ # 创建共定位掩模
1794
+ co_localized = (label1 > 0) & (label2 > 0)
1795
+ co_localized = morphology.remove_small_objects(co_localized, min_size=min_size)
1796
+ co_labeled = measure.label(co_localized)
1797
+
1798
+ # 提取共定位区域属性
1799
+ props = measure.regionprops_table(co_labeled, properties=["area", "bbox", "centroid"])
1800
+ df = pd.DataFrame(props)
1801
+ df["stain_pair"] = f"{stain1}_{stain2}"
1802
+
1803
+ return co_localized, co_labeled, df
1804
+
1805
+ def _init_custom_colormaps(self):
1806
+ """Initialize custom colormaps for specific stains"""
1807
+ # Hematoxylin (desaturated purple)
1808
+ hema_colors = [
1809
+ (0, 0, 0),
1810
+ (0.4, 0.2, 0.6),
1811
+ (0.8, 0.6, 0.9),
1812
+ ] # Dark purple to light purple
1813
+ self.STAIN_CONFIG["hematoxylin"]["cmap"] = LinearSegmentedColormap.from_list(
1814
+ "hema", hema_colors
1815
+ )
1816
+
1817
+ # Eosin (pinkish-red)
1818
+ eosin_colors = [
1819
+ (0, 0, 0),
1820
+ (0.9, 0.3, 0.4),
1821
+ (1, 0.9, 0.9),
1822
+ ] # Dark red to light pink
1823
+ self.STAIN_CONFIG["eosin"]["cmap"] = LinearSegmentedColormap.from_list(
1824
+ "eosin", eosin_colors
1825
+ )
1826
+
1827
+ # DAB (brown)
1828
+ dab_colors = [
1829
+ (0, 0, 0),
1830
+ (0.6, 0.4, 0.2),
1831
+ (0.9, 0.8, 0.6),
1832
+ ] # Dark brown to light brown
1833
+ self.STAIN_CONFIG["dab"]["cmap"] = LinearSegmentedColormap.from_list(
1834
+ "dab", dab_colors
1835
+ )
1836
+
1837
+ # DAPI (vivid blue)
1838
+ dapi_colors = [(0, 0, 0), (0.1, 0.1, 0.8), (0.6, 0.8, 1)]
1839
+ self.STAIN_CONFIG["dapi"]["cmap"] = LinearSegmentedColormap.from_list(
1840
+ "dapi", dapi_colors
1841
+ )
1842
+
1843
+ # FITC (vivid green)
1844
+ fitc_colors = [(0, 0, 0), (0.1, 0.8, 0.1), (0.8, 1, 0.8)]
1845
+ self.STAIN_CONFIG["fitc"]["cmap"] = LinearSegmentedColormap.from_list(
1846
+ "fitc", fitc_colors
1847
+ )
1848
+
1849
+ def _get_colormap(self, stain_type):
1850
+ """Get colormap optimized for specific stain types"""
1851
+ config = self.STAIN_CONFIG.get(stain_type.lower(), {})
1852
+
1853
+ if config.get("cmap"):
1854
+ return config["cmap"]
1855
+
1856
+ # Default colormaps for non-special stains
1857
+ cmaps = {
1858
+ "red": "Reds",
1859
+ "green": "Greens",
1860
+ "blue": "Blues",
1861
+ "purple": "Purples",
1862
+ "pink": "RdPu",
1863
+ "brown": "YlOrBr",
1864
+ "magenta": "magma",
1865
+ "black": "binary",
1866
+ }
1867
+ return cmaps.get(config.get("color", "").lower(), "viridis")
1868
+
1869
+ def plot(self, stains=None, figsize=(8, 8), alpha=None, n_col=2):
1870
+ """Enhanced visualization with stain-specific settings"""
1871
+ if stains is None:
1872
+ stains = list(self.results.keys())
1873
+
1874
+ n = len(stains) + 1 # Original + stains
1875
+ cols = min(n, n_col)
1876
+ rows = (n + cols - 1) // cols
1877
+ fig, axes = plt.subplots(rows, cols, figsize=figsize)
1878
+ axes = axes.ravel() if rows > 1 else axes
1879
+
1880
+ # Original image
1881
+ axes[0].imshow(self.image_rgb)
1882
+ axes[0].set_title("raw")
1883
+ axes[0].axis("off")
1884
+
1885
+ # Process each stain
1886
+ for i, stain in enumerate(stains, 1):
1887
+ if stain.lower() not in self.results:
1888
+ continue
1889
+ config = self.STAIN_CONFIG.get(stain.lower(), {})
1890
+ stain_alpha = alpha if alpha is not None else config.get("alpha", 0.4)
1891
+
1892
+ axes[i].imshow(self.image_rgb)
1893
+ axes[i].imshow(
1894
+ self.results[stain]["mask"],
1895
+ alpha=stain_alpha,
1896
+ cmap=self._get_colormap(stain),
1897
+ vmin=0,
1898
+ vmax=1,
1899
+ )
1900
+ axes[i].set_title(f"{stain.capitalize()}")
1901
+ axes[i].axis("off")
1902
+ # Hide unused axes
1903
+ for j in range(i + 1, len(axes)):
1904
+ axes[j].axis("off")
1905
+ # plt.tight_layout()
1906
+ def plot_counts(self, stain_type=None, show_labels=True,show_area=True, figsize=(10, 10),fontsize=9, label_color='yellow', alpha=0.4):
1907
+ """
1908
+ Display overlay of segmentation on original image with cell label counts.
1909
+ """
1910
+ if stain_type is None:
1911
+ # stain_type = list(self.results.keys())[0]
1912
+ stain_type = next(iter(self.results.keys()))
1913
+ print(f"'stain_type' not provided, using the first processed stain: {stain_type}")
1914
+ # Ensure we have filtered cells
1915
+ if not hasattr(self, 'count_cells_filtered'):
1916
+ print("Running cell counting first...")
1917
+ self.count_cells(stain_type)
1918
+ label_image = self.results[stain_type.lower()]["labeled"]
1919
+ # df = self.results[stain_type]["df"]
1920
+ # df=self.count_cells_filtered
1921
+
1922
+ overlay = color.label2rgb(label_image, image=self.image_rgb, bg_label=0, alpha=0.4)
1923
+
1924
+ fig, axs = plt.subplots(2,1,figsize=figsize)
1925
+ axs[0].imshow(self.image_rgb)
1926
+ axs[0].set_title(f"raw image")
1927
+
1928
+ axs[1].imshow(overlay)
1929
+ axs[1].set_title(f"Area - {stain_type.capitalize()}")
1930
+
1931
+ if show_labels:
1932
+ idx=1
1933
+ for _, row in self.count_cells_filtered.iterrows():
1934
+ y, x = row["centroid-0"], row["centroid-1"]
1935
+ if show_area:
1936
+ axs[0].text(x, y, f"{idx}", fontsize=fontsize, color=label_color, ha='center')
1937
+ axs[1].text(x, y, f"{idx}:{str(int(row["area"]))}", fontsize=fontsize, color=label_color, ha='center')
1938
+ else:
1939
+ axs[0].text(x, y, f"{idx}", fontsize=fontsize, color=label_color, ha='center')
1940
+ axs[1].text(x, y, f"{idx}", fontsize=fontsize, color=label_color, ha='center')
1941
+ idx+=1
1942
+ axs[0].axis("off")
1943
+ axs[1].axis("off")
1944
+ plt.tight_layout()
1945
+ def output(self, prefix="analysis"):
1946
+ """
1947
+ 保存所有结果到CSV文件并返回DataFrames
1948
+ 参数:
1949
+ prefix: 保存文件的前缀
1950
+ 返回:
1951
+ dict: 包含两个键:
1952
+ - 'combined': 所有结果的合并DataFrame
1953
+ - 'individual': 包含各染色单独结果的字典
1954
+ """
1955
+ all_dfs = []
1956
+ individual_dfs = {}
1957
+
1958
+ for stain, data in self.results.items():
1959
+ df = data["df"].copy()
1960
+ df["stain_type"] = stain # 确保每个DF都有染色类型列
1961
+ all_dfs.append(df)
1962
+ individual_dfs[stain] = df
1963
+
1964
+ # 创建返回字典
1965
+ result_dict = {
1966
+ "combined": (
1967
+ pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame()
1968
+ ),
1969
+ "individual": individual_dfs,
1970
+ }
1971
+ return result_dict
1972
+
1973
+ @staticmethod
1974
+ def dual_color(image,
1975
+ color1=["#8A4EC3", "#7E44C1"],
1976
+ color2=["#A6A3D2", "#B891D0"],
1977
+ bg_v_thresh=0.9,
1978
+ h_delta=0.04,
1979
+ s_delta=0.2,
1980
+ v_delta=0.2,
1981
+ allow_overlap=True,
1982
+ channel_mode='rgb',channels=None):
1983
+ """
1984
+ Enhanced dual color analysis supporting multiple images/channels.
1985
+ """
1986
+ def process_single_image(img, color_group, is_second=False, existing_mask=None,
1987
+ allow_overlap=True,):
1988
+ # Convert to appropriate color space
1989
+ if channel_mode.lower() == 'hed':
1990
+ img_conv = color.rgb2hed(img)
1991
+ img_processed = np.stack([img_conv[:,:,0], img_conv[:,:,1],
1992
+ img_conv[:,:,2]], axis=-1)
1993
+ else:
1994
+ img_processed = color.rgb2hsv(img)
1995
+
1996
+ mask = np.zeros(img_processed.shape[:2], dtype=bool)
1997
+ for hex_color in color_group:
1998
+ try:
1999
+ rgb_color = mcolors.to_rgb(hex_color)
2000
+ hsv_color = mcolors.rgb_to_hsv(rgb_color)
2001
+
2002
+ bounds = {
2003
+ "h_min": max(0, hsv_color[0] - h_delta),
2004
+ "h_max": min(1, hsv_color[0] + h_delta),
2005
+ "s_min": max(0, hsv_color[1] - s_delta),
2006
+ "s_max": min(1, hsv_color[1] + s_delta),
2007
+ "v_min": max(0, hsv_color[2] - v_delta),
2008
+ "v_max": min(1, hsv_color[2] + v_delta),
2009
+ }
2010
+
2011
+ H, S, V = img_processed[:,:,0], img_processed[:,:,1], img_processed[:,:,2]
2012
+ tissue_mask = V < bg_v_thresh
2013
+ new_mask = (
2014
+ (H >= bounds["h_min"]) & (H <= bounds["h_max"]) &
2015
+ (S >= bounds["s_min"]) & (S <= bounds["s_max"]) &
2016
+ (V >= bounds["v_min"]) & (V <= bounds["v_max"]) &
2017
+ tissue_mask
2018
+ )
2019
+
2020
+ if is_second and not allow_overlap and existing_mask is not None:
2021
+ new_mask = new_mask & ~existing_mask
2022
+ mask = mask | new_mask
2023
+
2024
+ except ValueError as e:
2025
+ print(f"Warning: Could not process color {hex_color}: {str(e)}")
2026
+ continue
2027
+
2028
+ return mask
2029
+ # Always use full RGB image for detection
2030
+ if isinstance(image, list):
2031
+ # If list provided, use first image as RGB source
2032
+ img_rgb = image[0] if image[0].ndim == 3 else np.stack([image[0]]*3, axis=-1)
2033
+ else:
2034
+ img_rgb = image if image.ndim == 3 else np.stack([image]*3, axis=-1)
2035
+ # # Handle input images
2036
+ # if not isinstance(image, list):
2037
+ # # Single image case
2038
+ # img1 = img2 = image
2039
+ # else:
2040
+ # # Multi-image/channel case
2041
+ # img1, img2 = image[0], image[1]
2042
+
2043
+ # # Ensure proper dimensionality
2044
+ # if img1.ndim == 2:
2045
+ # img1 = np.stack([img1]*3, axis=-1)
2046
+ # if img2.ndim == 2:
2047
+ # img2 = np.stack([img2]*3, axis=-1)
2048
+
2049
+ # Process stains
2050
+ mask1 = process_single_image(img_rgb, color1)
2051
+ mask2 = process_single_image(img_rgb, color2, is_second=True, existing_mask=mask1,allow_overlap=allow_overlap)
2052
+ if channels is not None:
2053
+ if len(channels) != 2:
2054
+ raise ValueError("channels must be a list of two channel indices")
2055
+
2056
+ # Create channel-specific masks
2057
+ mask1 = mask1 & (img_rgb[..., channels[0]] > 0)
2058
+ mask2 = mask2 & (img_rgb[..., channels[1]] > 0)
2059
+ # Calculate statistics
2060
+ area1, area2 = np.sum(mask1), np.sum(mask2)
2061
+ total_area = area1 + area2
2062
+ ratio1 = area1 / total_area if total_area > 0 else 0
2063
+ ratio2 = area2 / total_area if total_area > 0 else 0
2064
+
2065
+ # Prepare visualization image
2066
+ vis_img = img_rgb
2067
+
2068
+ return {
2069
+ "stain1_mask": mask1,
2070
+ "stain2_mask": mask2,
2071
+ "stain1_area": area1,
2072
+ "stain2_area": area2,
2073
+ "ratio_stain1": ratio1,
2074
+ "ratio_stain2": ratio2,
2075
+ "co_local_mask": mask1 & mask2,
2076
+ "image": vis_img / 255.0,
2077
+ "params": {
2078
+ "color1": color1,
2079
+ "color2": color2,
2080
+ "bg_v_thresh": bg_v_thresh,
2081
+ "h_delta": h_delta,
2082
+ "s_delta": s_delta,
2083
+ "v_delta": v_delta,
2084
+ "channel_mode": channel_mode
2085
+ }
2086
+ }
2087
+ def apply_dual_color(self, color1=["#8A4EC3", "#7E44C1"], color2=["#A6A3D2", "#B891D0"],
2088
+ bg_v_thresh=0.9, h_delta=0.04, s_delta=0.2, v_delta=0.2,allow_overlap=True, channels:list=None,channel_mode='rgb'):
2089
+ """
2090
+ Instance method wrapper for the static dual_color
2091
+ """
2092
+ if channels is not None:
2093
+ if len(channels) != 2:
2094
+ raise ValueError("channels must be a list of two channel indices")
2095
+
2096
+ channel1 = self.image_rgb[..., channels[0]]
2097
+ channel2 = self.image_rgb[..., channels[1]]
2098
+
2099
+ # Convert single channels to 3-channel by replicating
2100
+ if channel1.ndim == 2:
2101
+ channel1 = np.stack([channel1]*3, axis=-1)
2102
+ if channel2.ndim == 2:
2103
+ channel2 = np.stack([channel2]*3, axis=-1)
2104
+ result = self.dual_color([channel1, channel2],
2105
+ color1=color1,
2106
+ color2=color2,
2107
+ bg_v_thresh=bg_v_thresh,
2108
+ h_delta=h_delta,
2109
+ s_delta=s_delta,
2110
+ v_delta=v_delta,
2111
+ allow_overlap=allow_overlap,
2112
+ channel_mode=channel_mode)
2113
+ else:
2114
+ result = self.dual_color(self.image_rgb,
2115
+ color1=color1,
2116
+ color2=color2,
2117
+ bg_v_thresh=bg_v_thresh,
2118
+ h_delta=h_delta,
2119
+ s_delta=s_delta,
2120
+ v_delta=v_delta,
2121
+ allow_overlap=allow_overlap,
2122
+ channel_mode=channel_mode)
2123
+ result["image"] = self.image_rgb / 255.0 # Ensure image is available for plotting
2124
+ self.results["dual_color"] = result
2125
+ return result
2126
+ def plot_dual_color(self, figsize=(12, 8), stain1_name="Stain 1", stain2_name="Stain 2",mask_cmaps=("gray", "gray"),paint1="purple",paint2="pink",show_colocal=False):
2127
+ """
2128
+ Visualize dual color analysis results
2129
+
2130
+ Parameters:
2131
+ figsize: Tuple (width, height) of figure size
2132
+ stain1_name: Label for first stain
2133
+ stain2_name: Label for second stain
2134
+
2135
+ Returns:
2136
+ numpy.ndarray: Array of matplotlib Axes objects (2x2 grid)
2137
+ """
2138
+ if "dual_color" not in self.results:
2139
+ self.dual_color()
2140
+
2141
+ res = self.results["dual_color"]
2142
+ if show_colocal:
2143
+ fig, axes = plt.subplots(3, 2, figsize=figsize)
2144
+ else:
2145
+ fig, axes = plt.subplots(2, 2, figsize=figsize)
2146
+ axes = axes.ravel()
2147
+
2148
+ # Raw image
2149
+ axes[0].imshow(res["image"])
2150
+ axes[0].set_title("Raw Image")
2151
+ axes[0].axis("off")
2152
+
2153
+ # First stain mask
2154
+ axes[1].imshow(res["stain1_mask"], cmap=mask_cmaps[0])
2155
+ axes[1].set_title(stain1_name)
2156
+ axes[1].axis("off")
2157
+
2158
+ # Second stain mask
2159
+ axes[2].imshow(res["stain2_mask"], cmap=mask_cmaps[1])
2160
+ axes[2].set_title(stain2_name)
2161
+ axes[2].axis("off")
2162
+ if show_colocal:
2163
+ # Co-local
2164
+ colocal = np.zeros_like(res["image"])
2165
+ c1,c2=color2rgb(paint1),color2rgb(paint2)
2166
+ co_color=(np.mean([c1[0],c2[0]],axis=0),np.mean([c1[1],c2[1]],axis=0),np.mean([c1[2],c2[2]],axis=0))
2167
+ co_color = tuple(np.mean([color1, color2], axis=0))
2168
+
2169
+ colocal[ res["stain1_mask"]&res["stain2_mask"]] = co_color
2170
+ axes[4].imshow(colocal)
2171
+ axes[4].set_title("Colocal")
2172
+ axes[4].axis("off")
2173
+ # Overlay
2174
+ overlay = np.zeros_like(res["image"])
2175
+ overlay[res["stain1_mask"]] = color2rgb(paint1) # purple
2176
+ overlay[res["stain2_mask"]] = color2rgb(paint2) # pink
2177
+ axes[3].imshow(overlay)
2178
+ axes[3].set_title("Overlay")
2179
+ axes[3].axis("off")
2180
+ return axes
2181
+ def preview(self, channels=None, method='rgb', figsize=(12, 8), cmap='viridis',return_fig=False):
2182
+ """
2183
+ Quick preview of image channels to assess composition before analysis.
2184
+
2185
+ Summary Table: Which Method to Use?
2186
+
2187
+ Image Type Best Method When to Use Example Use Case
2188
+ Fluorescence (DAPI/FITC/TRITC) 'rgb' If channels are cleanly separated (e.g., DAPI in blue, FITC in green). Immunofluorescence (IF) with clear channel separation.
2189
+ Fluorescence (Overlap) 'hsv' If signals overlap (e.g., FITC + TRITC mixing). Colocalization analysis.
2190
+ Brightfield (H&E Stains) 'hed' Hematoxylin (nuclei) + Eosin (cytoplasm). Histopathology (tissue analysis).
2191
+ Brightfield (IHC, DAB) 'hed' DAB (brown) detection in IHC.
2192
+
2193
+ Parameters:
2194
+ - channels: List of channels to preview. Options depend on method:
2195
+ - For 'rgb': ['R','G','B'] or [0,1,2]
2196
+ - For 'hed': [0,1,2] (H,E,D)
2197
+ - For 'hsv': [0,1,2] (H,S,V)
2198
+ - method: Decomposition method ('rgb', 'hed', or 'hsv')
2199
+ - figsize: Figure size
2200
+ - cmap: Colormap for single channel display
2201
+
2202
+ Returns:
2203
+ - Matplotlib figure
2204
+ """
2205
+ if channels is None:
2206
+ channels = [0, 1, 2] if method != 'rgb' else ['R', 'G', 'B']
2207
+
2208
+ # Convert image based on method
2209
+ if method.lower() == 'hed':
2210
+ img = color.rgb2hed(self.image_rgb)
2211
+ channel_names = ['Hematoxylin', 'Eosin', 'DAB']
2212
+ elif method.lower() == 'hsv':
2213
+ img = color.rgb2hsv(self.image_rgb)
2214
+ channel_names = ['Hue', 'Saturation', 'Value']
2215
+ else: # default to RGB
2216
+ img = self.image_rgb
2217
+ channel_names = ['Red', 'Green', 'Blue']
2218
+ method = 'rgb' # ensure consistent behavior
2219
+
2220
+ fig, axes = plt.subplots(1, len(channels)+1, figsize=figsize)
2221
+
2222
+ # Show original image
2223
+ axes[0].imshow(self.image_rgb)
2224
+ axes[0].set_title('Original')
2225
+ axes[0].axis('off')
2226
+
2227
+ # Show each requested channel
2228
+ for i, channel in enumerate(channels, 1):
2229
+ if method == 'rgb':
2230
+ if isinstance(channel, int):
2231
+ channel_idx = channel
2232
+ channel_name = channel_names[channel_idx]
2233
+ else:
2234
+ channel_map = {'R':0, 'G':1, 'B':2}
2235
+ channel_idx = channel_map[channel.upper()]
2236
+ channel_name = channel_names[channel_idx]
2237
+ channel_img = img[:, :, channel_idx]
2238
+ else:
2239
+ channel_idx = channel if isinstance(channel, int) else int(channel)
2240
+ channel_img = img[:, :, channel_idx]
2241
+ channel_name = channel_names[channel_idx]
2242
+
2243
+ axes[i].imshow(channel_img, cmap=cmap)
2244
+ axes[i].set_title(f'{channel_name} Channel')
2245
+ axes[i].axis('off')
2246
+ if return_fig:
2247
+ return fig
2248
+ plt.tight_layout()
2249
+ plt.show()