py2ls 0.2.5.12__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 +1955 -296
- py2ls/ips.py +1278 -608
- py2ls/netfinder.py +12 -5
- py2ls/plot.py +13 -7
- py2ls/stats.py +1 -144
- {py2ls-0.2.5.12.dist-info → py2ls-0.2.5.14.dist-info}/METADATA +1 -1
- {py2ls-0.2.5.12.dist-info → py2ls-0.2.5.14.dist-info}/RECORD +8 -8
- {py2ls-0.2.5.12.dist-info → py2ls-0.2.5.14.dist-info}/WHEEL +1 -1
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
|
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
52
|
+
img_array = np.array(img)
|
53
|
+
|
54
|
+
# Visualization
|
20
55
|
if plot:
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
35
|
-
obj_min=50,
|
36
|
-
hole_min=50,
|
37
|
-
|
38
|
-
plot=False,
|
39
|
-
cmap="
|
40
|
-
)
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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.
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
#
|
87
|
-
markers
|
88
|
-
markers[img
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
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(
|
160
|
-
|
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.
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
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
|
-
|
192
|
-
normalized_image = image_array / 255.0
|
341
|
+
pil_img, img_array = open_img(img, convert=convert, plot=plot)
|
193
342
|
else:
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
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
|
-
|
215
|
-
#
|
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
|
-
"
|
218
|
-
"
|
219
|
-
"
|
220
|
-
"
|
221
|
-
"
|
222
|
-
"
|
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
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
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
|
-
#
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
#
|
295
|
-
|
296
|
-
#
|
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 =
|
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
|
-
|
487
|
-
img_label
|
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
|
-
|
491
|
-
img_label2rgb
|
492
|
-
|
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()
|