dsa-helpers 1.1.19.dev2__tar.gz → 2.0.0__tar.gz

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.
Files changed (56) hide show
  1. {dsa_helpers-1.1.19.dev2/dsa_helpers.egg-info → dsa_helpers-2.0.0}/PKG-INFO +2 -1
  2. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/__init__.py +1 -1
  3. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/gpd_utils.py +84 -2
  4. dsa_helpers-2.0.0/dsa_helpers/ml/segformer_semantic_segmentation/inference.py +571 -0
  5. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/segformer_semantic_segmentation/train.py +1 -1
  6. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0/dsa_helpers.egg-info}/PKG-INFO +2 -1
  7. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers.egg-info/requires.txt +1 -0
  8. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/pyproject.toml +3 -2
  9. dsa_helpers-1.1.19.dev2/dsa_helpers/ml/segformer_semantic_segmentation/inference.py +0 -231
  10. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/.gitignore +0 -0
  11. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/.readthedocs.yaml +0 -0
  12. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/LICENSE +0 -0
  13. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/README.md +0 -0
  14. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/app.py +0 -0
  15. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dev.py +0 -0
  16. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/docker/Dockerfile +0 -0
  17. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/docker/README.md +0 -0
  18. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/docs/requirements.txt +0 -0
  19. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/docs/source/conf.py +0 -0
  20. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/docs/source/index.rst +0 -0
  21. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/cli_utils.py +0 -0
  22. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/dash/__init__.py +0 -0
  23. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/dash/dash_paperdragon_utils.py +0 -0
  24. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/dash/header.py +0 -0
  25. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/dash/header_callbacks.py +0 -0
  26. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/girder_utils.py +0 -0
  27. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/image_utils.py +0 -0
  28. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/imread.py +0 -0
  29. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/imwrite.py +0 -0
  30. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/__init__.py +0 -0
  31. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/callbacks.py +0 -0
  32. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/datasets/SegFormerSegmentationDataset.py +0 -0
  33. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/datasets/__init__.py +0 -0
  34. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/datasets/utils.py +0 -0
  35. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/evaluate.py +0 -0
  36. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/metrics.py +0 -0
  37. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/object_detection/__init__.py +0 -0
  38. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/object_detection/get_pearce_roi_images.py +0 -0
  39. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/object_detection/tile_image.py +0 -0
  40. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/object_detection/utils.py +0 -0
  41. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/segformer_semantic_segmentation/__init__.py +0 -0
  42. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/segformer_semantic_segmentation/datasets.py +0 -0
  43. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/segformer_semantic_segmentation/evaluate.py +0 -0
  44. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/segformer_semantic_segmentation/transforms.py +0 -0
  45. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/segformer_semantic_segmentation/utils.py +0 -0
  46. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/transforms/__init__.py +0 -0
  47. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/ml/transforms/segformer_transforms.py +0 -0
  48. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/mongo_utils.py +0 -0
  49. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/tile_utils.py +0 -0
  50. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/tiling.py +0 -0
  51. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers/utils.py +0 -0
  52. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers.egg-info/SOURCES.txt +0 -0
  53. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers.egg-info/dependency_links.txt +0 -0
  54. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/dsa_helpers.egg-info/top_level.txt +0 -0
  55. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/requirements.txt +0 -0
  56. {dsa_helpers-1.1.19.dev2 → dsa_helpers-2.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dsa_helpers
3
- Version: 1.1.19.dev2
3
+ Version: 2.0.0
4
4
  Summary: Utility functions for working with the DSA girder client.
5
5
  Author-email: "Juan C. Vizcarra" <jvizcar@emory.edu>
6
6
  Maintainer-email: "Juan C. Vizcarra" <jvizcar@emory.edu>
@@ -29,6 +29,7 @@ Requires-Dist: large-image
29
29
  Requires-Dist: rasterio
30
30
  Requires-Dist: colorama
31
31
  Requires-Dist: albumentations
32
+ Requires-Dist: rdp
32
33
  Provides-Extra: opencv
33
34
  Requires-Dist: opencv-python; extra == "opencv"
34
35
  Provides-Extra: opencv-headless
@@ -3,7 +3,7 @@ from .imread import imread
3
3
  from .imwrite import imwrite
4
4
 
5
5
  # Version of the dsa-helpers package
6
- __version__ = "1.1.19dev2"
6
+ __version__ = "2.0.0"
7
7
 
8
8
  # To avoid slow downs, do not allow from dsa_helpers import * to import anything.
9
9
  __all__ = []
@@ -11,13 +11,95 @@ Functions:
11
11
  """
12
12
 
13
13
  import geopandas as gpd
14
- from shapely.geometry import Polygon
15
- from rasterio.features import rasterize
16
14
  import numpy as np
15
+ from rasterio.features import rasterize
16
+ from rdp import rdp
17
+ from tqdm import tqdm
18
+
19
+ from shapely.geometry import Polygon
20
+ from shapely.ops import unary_union
21
+
17
22
  import matplotlib.patches as mpatches
18
23
  import matplotlib.pyplot as plt
19
24
 
20
25
 
26
+ def make_multi_polygons(
27
+ gdf: gpd.GeoDataFrame, label_col: str
28
+ ) -> gpd.GeoDataFrame:
29
+ """Make the input GeoDataFrame polygons into multi-polygons, grouped
30
+ by the label column.
31
+
32
+ Args:
33
+ gdf (geopandas.GeoDataFrame): The input GeoDataFrame.
34
+ label_col (str): The column to group by.
35
+
36
+ Returns:
37
+ geopandas.GeoDataFrame: The output GeoDataFrame.
38
+
39
+ """
40
+ unique_labels = gdf[label_col].unique()
41
+
42
+ data = []
43
+
44
+ for label in tqdm(unique_labels, desc="Making multi-polygons"):
45
+ label_df = gdf[gdf[label_col] == label]
46
+
47
+ if len(label_df) == 1:
48
+ data.append(label_df.iloc[0].copy())
49
+ else:
50
+ r = label_df.iloc[0].copy()
51
+
52
+ geom = unary_union(label_df["geometry"].tolist())
53
+
54
+ r["geometry"] = geom
55
+
56
+ data.append(r)
57
+
58
+ gdf = gpd.GeoDataFrame(data).reset_index(drop=True)
59
+
60
+ return gdf
61
+
62
+
63
+ def rdp_by_fraction_of_max_dimension(geom, fraction=0.001):
64
+ # First grab the exterior coordinates.
65
+ coords = np.array(geom.exterior.coords)[:, :2]
66
+
67
+ # Get the bounding box dimensions
68
+ bounds = geom.bounds # (minx, miny, maxx, maxy)
69
+ width = bounds[2] - bounds[0]
70
+ height = bounds[3] - bounds[1]
71
+ max_dimension = max(width, height)
72
+
73
+ # Set epsilon as a small fraction of the max dimension
74
+ epsilon = max_dimension * fraction
75
+
76
+ # Run RDP on the exterior of the polygon.
77
+ mask = rdp(coords, algo="iter", return_mask=True, epsilon=epsilon)
78
+ exterior_coords = coords[mask]
79
+
80
+ # Run on the interior polygons if any.
81
+ interiors = []
82
+ if geom.interiors:
83
+ for interior in geom.interiors:
84
+ # Similary, calculate the epsilon for the interior.
85
+ bounds = interior.bounds
86
+ width = bounds[2] - bounds[0]
87
+ height = bounds[3] - bounds[1]
88
+ max_dimension = max(width, height)
89
+ epsilon = max_dimension * fraction
90
+
91
+ # Run RDP on the interior.
92
+ coords = np.array(interior.coords)[:, :2]
93
+ mask = rdp(coords, algo="iter", return_mask=True, epsilon=epsilon)
94
+ masked_coords = coords[mask]
95
+ interiors.append(masked_coords)
96
+
97
+ # Combine the exterior and interiors.
98
+ geom = Polygon(exterior_coords, interiors)
99
+
100
+ return geom
101
+
102
+
21
103
  def count_polygon_points(polygon: Polygon) -> int:
22
104
  """
23
105
  Count the total number of points in a Shapely polygon, including
@@ -0,0 +1,571 @@
1
+ import torch
2
+ import geopandas as gpd
3
+ import pandas as pd
4
+ import histomicstk as htk
5
+ import numpy as np
6
+ from transformers import (
7
+ SegformerForSemanticSegmentation,
8
+ SegformerImageProcessor,
9
+ )
10
+ from large_image_eager_iterator import LargeImagePrefetch
11
+ from PIL import Image
12
+ from time import perf_counter
13
+ from large_image import getTileSource
14
+ from tqdm import tqdm
15
+ from multiprocessing import Pool
16
+
17
+ from shapely import make_valid
18
+ from shapely.affinity import scale
19
+ from shapely.geometry import Polygon
20
+ from shapely.ops import unary_union
21
+
22
+ from ...image_utils import label_mask_to_polygons
23
+ from ...gpd_utils import (
24
+ remove_gdf_overlaps,
25
+ rdp_by_fraction_of_max_dimension,
26
+ make_multi_polygons,
27
+ )
28
+
29
+ stain_color_map = htk.preprocessing.color_deconvolution.stain_color_map
30
+
31
+ # specify stains of input image
32
+ stains = [
33
+ "hematoxylin", # nuclei stain
34
+ "eosin", # cytoplasm stain
35
+ "null",
36
+ ] # set to null if input contains only two stains
37
+
38
+ # create stain matrix
39
+ W = np.array([stain_color_map[st] for st in stains]).T
40
+
41
+
42
+ def inference(
43
+ model: str | torch.nn.Module,
44
+ wsi_fp: str,
45
+ batch_size: int = 16,
46
+ tile_size: int = 512,
47
+ mag: float | None = None,
48
+ workers: int = 8,
49
+ chunk_mult: int = 2,
50
+ prefetch: int = 2,
51
+ device: str | None = None,
52
+ tolerance: float | None = 2.0,
53
+ min_area: float = 1000,
54
+ hematoxylin_channel: bool = False,
55
+ ) -> gpd.GeoDataFrame:
56
+ """Inference using SegFormer semantic segmentation model on a WSI.
57
+
58
+ Args:
59
+ model (str | torch.nn.Module): Path to the model checkpoint or
60
+ a pre-loaded model.
61
+ wsi_fp (str): File path to the WSI.
62
+ batch_size (int, optional): Batch size for inference. Defaults
63
+ to 16.
64
+ tile_size (int, optional): Tile size for inference. Defaults to
65
+ 512.
66
+ mag (float, optional): Magnification for inference. Defaults to
67
+ None, which will use the scan magnification of WSI.
68
+ workers (int, optional): Number of workers for inference.
69
+ Defaults to 8.
70
+ chunk_mult (int, optional): Chunk multiplier for inference.
71
+ Defaults to 2.
72
+ prefetch (int, optional): Number of prefetch for inference.
73
+ Defaults to 2.
74
+ device (str, optional): Device for inference. Default is None,
75
+ will use "gpu" if available, otherwise "cpu".
76
+ tolerance (float | None, optional): Tolerance for simplification
77
+ of the predicted polygons. Guidelines: 0.5 for mild
78
+ simplification, 1.0 for moderate, 2.0 for more aggressive.
79
+ If set to None or 0.0, the polygons will not be simplified.
80
+ min_area (float, optional): Minimum area of a polygon to be
81
+ simplified. This is added to avoid simplifying small
82
+ polygons that would probably lose too much information.
83
+ Defaults to 1000.
84
+ hematoxylin_channel (bool, optional): Whether to use the
85
+ hematoxylin channel when predicting the segmentation mask.
86
+ Defaults to False.
87
+
88
+ Returns:
89
+ gpd.GeoDataFrame: A GeoDataFrame containing the predicted
90
+ polygons and labels.
91
+
92
+ """
93
+ # Initiate the tile iterator.
94
+ iterator = LargeImagePrefetch(
95
+ wsi_fp,
96
+ batch=batch_size,
97
+ tile_size=(tile_size, tile_size),
98
+ scale_mode="mag",
99
+ target_scale=mag,
100
+ workers=workers,
101
+ chunk_mult=chunk_mult,
102
+ prefetch=prefetch,
103
+ nchw=False,
104
+ icc=True,
105
+ )
106
+
107
+ if device is None:
108
+ device = "cuda" if torch.cuda.is_available() else "cpu"
109
+ print(f"Using device: {device}")
110
+
111
+ device = torch.device(device)
112
+
113
+ # Load the model.
114
+ if isinstance(model, str):
115
+ model = SegformerForSemanticSegmentation.from_pretrained(
116
+ model, local_files_only=True, device_map=device
117
+ )
118
+
119
+ model.eval()
120
+
121
+ # Iterate through batches.
122
+ batch_n = 0
123
+
124
+ # Image processor for images.
125
+ processor = SegformerImageProcessor()
126
+
127
+ # Track all predicted polygons.
128
+ wsi_polygons = []
129
+
130
+ # Scaling factor, multiply to go from scan magnification to desired mag.
131
+ ts_metadata = getTileSource(wsi_fp).getMetadata()
132
+ scan_mag = ts_metadata["magnification"]
133
+
134
+ if mag is None:
135
+ mag = scan_mag
136
+ sf = 1.0
137
+ else:
138
+ sf = mag / scan_mag
139
+
140
+ for batch in iterator:
141
+ # Get the batch of images.
142
+ imgs = batch[0].view() # returns a numpy array of shape (N, H, W, C)
143
+ coordinates = batch[1]
144
+
145
+ if hematoxylin_channel:
146
+ img_list = []
147
+
148
+ for img in imgs:
149
+ img = (
150
+ htk.preprocessing.color_deconvolution.color_deconvolution(
151
+ img, W
152
+ ).Stains[:, :, 0]
153
+ )
154
+ img = np.stack([img, img, img], axis=-1)
155
+ img_list.append(img)
156
+
157
+ imgs = img_list
158
+
159
+ # Convert the numpy arrays to PIL images.
160
+ imgs = [Image.fromarray(img) for img in imgs]
161
+
162
+ # Pass the images through the processor.
163
+ inputs = processor(imgs, return_tensors="pt")
164
+ inputs = inputs.to(model.device)
165
+
166
+ # Predict on the batch.
167
+ with torch.no_grad():
168
+ output = model(inputs["pixel_values"])
169
+ logits = output.logits
170
+
171
+ # Get the logits out, resizing them to the original tile size.
172
+ logits = torch.nn.functional.interpolate(
173
+ logits,
174
+ size=tile_size,
175
+ mode="bilinear",
176
+ )
177
+
178
+ # Get predicted class labels for each pixel.
179
+ masks = torch.argmax(logits, dim=1).detach().cpu().numpy()
180
+
181
+ # Loop through each mask to extract the contours as shapely polygons.
182
+ for i, mask in enumerate(masks):
183
+ img_metadata = coordinates[i]
184
+ x, y = img_metadata[6], img_metadata[4]
185
+ x = int(x * sf)
186
+ y = int(y * sf)
187
+
188
+ polygon_and_labels = label_mask_to_polygons(
189
+ mask,
190
+ x_offset=x,
191
+ y_offset=y,
192
+ )
193
+
194
+ for polygon_and_label in polygon_and_labels:
195
+ polygon, label = polygon_and_label
196
+ label = int(label)
197
+
198
+ # Do something with the polygon and label.
199
+ wsi_polygons.append([polygon, label])
200
+
201
+ batch_n += 1
202
+ print(f"\r Processed batch {batch_n}. ", end="")
203
+ print()
204
+
205
+ # Convert polygons and labels to a GeoDataFrame.
206
+ gdf = gpd.GeoDataFrame(wsi_polygons, columns=["geometry", "label"])
207
+
208
+ gdf["geometry"] = gdf["geometry"].buffer(1)
209
+ gdf = gdf.dissolve(by="label", as_index=False)
210
+ gdf = gdf.explode(index_parts=False).reset_index(drop=True)
211
+
212
+ if tolerance:
213
+ # Add the area column.
214
+ gdf["area"] = gdf["geometry"].area
215
+
216
+ # Split the dataframe into those polygons that need to be simplified and those that do not.
217
+ gdf_to_simplify = gdf[gdf["area"] > min_area].reset_index(drop=True)
218
+ gdf_not_to_simplify = gdf[gdf["area"] <= min_area].reset_index(
219
+ drop=True
220
+ )
221
+
222
+ # Simplify the polygons that need to be simplified.
223
+ gdf_to_simplify["geometry"] = gdf_to_simplify["geometry"].simplify(
224
+ tolerance=tolerance, preserve_topology=True
225
+ )
226
+
227
+ # Concatenate the simplified polygons with the polygons that do not need to be simplified.
228
+ gdf = pd.concat(
229
+ [gdf_to_simplify, gdf_not_to_simplify], ignore_index=True
230
+ )
231
+
232
+ # Remove overlapping polygons.
233
+ no_overlap_gdf = remove_gdf_overlaps(
234
+ gdf,
235
+ columns="label",
236
+ )
237
+
238
+ # Scale the polygon to scan magnification.
239
+ no_overlap_gdf["geometry"] = no_overlap_gdf["geometry"].apply(
240
+ lambda geom: scale(geom, xfact=1 / sf, yfact=1 / sf, origin=(0, 0))
241
+ )
242
+
243
+ return no_overlap_gdf
244
+
245
+
246
+ class SegFormerSSInferenceCleanup:
247
+ def __init__(
248
+ self,
249
+ gdf: gpd.GeoDataFrame,
250
+ label_ranks: list[int],
251
+ small_hole_thr: int = 50000,
252
+ buffer: int = 1,
253
+ fraction: float = 0.001,
254
+ nproc: int = 20,
255
+ interior_max_area: int = 100000,
256
+ ):
257
+ """Initiate the class for cleaning up the inference output.
258
+
259
+ Args:
260
+ gdf (geopandas.GeoDataFrame): Input inference output.
261
+ label_ranks (list[int]): List of labels, ordered by rank
262
+ with index 0 being the lowest rank.
263
+ small_hole_thr (int, optional): Threshold in area to
264
+ identify small objects. Defaults to 50000.
265
+ buffer (int, optional): Buffer to add to polygons before
266
+ dissolving. Defaults to 1.
267
+ fraction (float, optional): Fraction of the maximum
268
+ dimension to use for RDP. Defaults to 0.001.
269
+ nproc (int, optional): Number of processes to use for
270
+ parallel RDP. Defaults to 20.
271
+ interior_max_area (int, optional): Maximum area of a hole
272
+ to fill. Used when filling gaps created by RDP.
273
+ Defaults to 100000.
274
+
275
+ """
276
+ for i, r in gdf.iterrows():
277
+ label = r["label"]
278
+
279
+ if label not in label_ranks:
280
+ raise ValueError(f"Label {label} not in label_ranks.")
281
+ gdf.loc[i, "rank"] = label_ranks.index(r["label"])
282
+
283
+ self.__version__ = "1.0.0"
284
+ self.input_gdf = gdf
285
+ self.small_hole_thr = small_hole_thr
286
+ self.output_gdf = None
287
+ self.buffer = buffer
288
+ self.fraction = fraction
289
+ self.nproc = nproc
290
+ self.interior_max_area = interior_max_area
291
+ self.time = {}
292
+
293
+ def _make_gpd_valid(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
294
+ # Make the geometries valid in the gdf, keep only polygons.
295
+ gdf["geometry"] = gdf["geometry"].apply(make_valid)
296
+ gdf = gdf.explode(index_parts=False)
297
+ gdf = gdf[
298
+ (gdf["geometry"].geom_type == "Polygon")
299
+ & (gdf["geometry"].is_valid)
300
+ ]
301
+
302
+ return gdf.reset_index(drop=True)
303
+
304
+ def _remove_intersections(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
305
+ # Remove intersections between polygons.
306
+ gdf = gdf.reset_index(drop=True)
307
+ n = len(gdf)
308
+
309
+ if n in (0, 1):
310
+ return gdf
311
+
312
+ # Loop until the second to last row.
313
+ for i in tqdm(
314
+ range(n - 1), total=n - 1, desc="Removing intersections"
315
+ ):
316
+ r1 = gdf.iloc[i]
317
+
318
+ # Subtract the r1 geometry from all others.
319
+ for j in range(i + 1, n):
320
+ r2 = gdf.iloc[j]
321
+
322
+ # Subtract r1 from r2.
323
+ geom = r2["geometry"].difference(r1["geometry"])
324
+
325
+ gdf.loc[j, "geometry"] = geom
326
+
327
+ return self._make_gpd_valid(gdf)
328
+
329
+ def _remove_small_holes(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
330
+ gdf = gdf.reset_index(drop=True)
331
+
332
+ n = len(gdf)
333
+
334
+ for i in tqdm(range(n), total=n, desc="Removing small holes"):
335
+ geom = gdf.iloc[i]["geometry"]
336
+
337
+ exterior = geom.exterior
338
+ interiors = geom.interiors
339
+
340
+ new_interiors = []
341
+ for interior in interiors:
342
+ if Polygon(interior).area > self.small_hole_thr:
343
+ new_interiors.append(interior)
344
+
345
+ geom = Polygon(exterior, new_interiors)
346
+
347
+ gdf.loc[i, "geometry"] = geom
348
+
349
+ return gdf
350
+
351
+ def _remove_small_contained_polygons(
352
+ self, gdf: gpd.GeoDataFrame
353
+ ) -> gpd.GeoDataFrame:
354
+ n = len(gdf)
355
+
356
+ i_removed = []
357
+
358
+ for i in tqdm(
359
+ range(n), total=n, desc="Removing small contained polygons"
360
+ ):
361
+ exterior = gdf.iloc[i]["geometry"].exterior
362
+
363
+ geom = Polygon(exterior)
364
+
365
+ if geom.area < self.small_hole_thr:
366
+ # Check if this is contained in another polygon.
367
+ contained = gdf[
368
+ (gdf["geometry"].contains(geom)) & (gdf.index != i)
369
+ ]
370
+
371
+ if len(contained):
372
+ i_removed.append(i)
373
+
374
+ gdf = gdf.drop(i_removed)
375
+
376
+ return gdf
377
+
378
+ def _remove_small_polygons_not_contained(
379
+ self, gdf: gpd.GeoDataFrame
380
+ ) -> gpd.GeoDataFrame:
381
+ gdf = gdf.reset_index(drop=True)
382
+ n = len(gdf)
383
+
384
+ i_removed = []
385
+
386
+ for i in tqdm(
387
+ range(n), total=n, desc="Removing small polygons not contained"
388
+ ):
389
+ geom = gdf.iloc[i]["geometry"]
390
+
391
+ if geom.geom_type == "MultiPolygon":
392
+ area = geom.area
393
+ else:
394
+ area = Polygon(geom.exterior).area
395
+
396
+ if area < self.small_hole_thr:
397
+ # Check for any touching polygons.
398
+ touching = gdf[
399
+ (~gdf.index.isin(i_removed + [i]))
400
+ & (gdf["geometry"].touches(geom))
401
+ ].copy()
402
+ if len(touching):
403
+ touching["intersection_length"] = (
404
+ touching["geometry"].intersection(geom).length
405
+ )
406
+
407
+ touching = touching.sort_values(
408
+ by="intersection_length", ascending=False
409
+ )
410
+
411
+ r = touching.iloc[0]
412
+
413
+ touching_geom = r["geometry"]
414
+
415
+ # Merge the polygons.
416
+ geom = geom.union(touching_geom)
417
+
418
+ gdf.loc[r.name, "geometry"] = geom
419
+ i_removed.append(i)
420
+ else:
421
+ # Remove this polygon.
422
+ i_removed.append(i)
423
+
424
+ gdf = gdf.drop(i_removed)
425
+ gdf = self._make_gpd_valid(gdf)
426
+ return gdf
427
+
428
+ def _rdp_polygon(self, geom, idx, fraction):
429
+ geom = rdp_by_fraction_of_max_dimension(geom, fraction=fraction)
430
+ return geom, idx
431
+
432
+ def _fill_rdp_gaps(
433
+ self, gdf: gpd.GeoDataFrame, interior_max_area: int = 100000
434
+ ) -> gpd.GeoDataFrame:
435
+ # Fill the gaps between polygons created by RDP.
436
+ gdf["area"] = gdf["geometry"].area
437
+
438
+ gdf_union = gdf["geometry"].union_all()
439
+
440
+ # Collect all the holes / interiors.
441
+ interiors = []
442
+
443
+ for geom in gdf_union.geoms:
444
+ for interior in geom.interiors:
445
+ interior = Polygon(interior)
446
+
447
+ if interior.area < interior_max_area:
448
+ interiors.append(interior)
449
+
450
+ # Loop through each hole that was small.
451
+ for interior in tqdm(interiors, desc="Filling holes between polygons"):
452
+ # Check polygons that are touching this interior.
453
+ touching = gdf[gdf["geometry"].distance(interior) == 0]
454
+
455
+ unique_ranks = touching["rank"].unique()
456
+
457
+ if len(unique_ranks) > 1:
458
+ # Sort by rank then area.
459
+ touching = touching.sort_values(
460
+ by=["rank", "area"], ascending=False
461
+ )
462
+
463
+ # Get the first row.
464
+ r = touching.iloc[0]
465
+
466
+ # Merge the hole with the polygon.
467
+ geom = unary_union([r["geometry"], interior])
468
+
469
+ if geom.geom_type == "MultiPolygon":
470
+ # Buff the interior a bit.
471
+ interior_buffed = interior.buffer(1)
472
+ geom = unary_union([interior_buffed, geom])
473
+
474
+ if geom.geom_type == "MultiPolygon":
475
+ print(
476
+ "MultiPolygon after buffering and union, discarding hole."
477
+ )
478
+ continue
479
+
480
+ gdf.loc[r.name, "geometry"] = geom
481
+ gdf.loc[r.name, "area"] = geom.area
482
+
483
+ return gdf
484
+
485
+ def cleanup(self):
486
+ """Pipeline for cleaning up the inference output."""
487
+ print("Running inference cleanup:\n")
488
+ time = self.time
489
+ gdf = self.input_gdf.copy()
490
+
491
+ print("[1/9] Applying buffer...")
492
+ start_time = perf_counter()
493
+ gdf["geometry"] = gdf["geometry"].buffer(self.buffer)
494
+ time["buffer"] = perf_counter() - start_time
495
+ gdf = self._make_gpd_valid(gdf)
496
+
497
+ print("[2/9] Dissolving...")
498
+ start_time = perf_counter()
499
+ gdf = gdf.dissolve(by="label", as_index=False)
500
+ time["dissolve"] = perf_counter() - start_time
501
+ gdf = gdf.explode(index_parts=False).reset_index(drop=True)
502
+ gdf = self._make_gpd_valid(gdf)
503
+
504
+ print("[3/9] Removing intersections...")
505
+ start_time = perf_counter()
506
+ gdf = make_multi_polygons(gdf, "label")
507
+ gdf = self._remove_intersections(gdf)
508
+ time["remove-intersections"] = perf_counter() - start_time
509
+
510
+ print("[4/9] Removing small holes...")
511
+ gdf = gdf.explode(index_parts=False).reset_index(drop=True)
512
+ gdf = self._make_gpd_valid(gdf)
513
+ start_time = perf_counter()
514
+ gdf = self._remove_small_holes(gdf)
515
+ time["remove-small-holes"] = perf_counter() - start_time
516
+
517
+ print("[5/9] Removing small polygons contained in other polygons...")
518
+ start_time = perf_counter()
519
+ gdf = self._remove_small_contained_polygons(gdf)
520
+ time["remove-small-contained-polygons"] = perf_counter() - start_time
521
+
522
+ print("[6/9] Removing small polygons not contained...")
523
+ start_time = perf_counter()
524
+ gdf = self._remove_small_polygons_not_contained(gdf)
525
+ time["remove-small-polygons-not-contained"] = (
526
+ perf_counter() - start_time
527
+ )
528
+
529
+ # Parallel RDP.
530
+ print("[7/9] Reducing points in polygons via RDP...")
531
+ start_time = perf_counter()
532
+ with Pool(processes=self.nproc) as pool:
533
+ jobs = [
534
+ pool.apply_async(
535
+ func=self._rdp_polygon,
536
+ args=(r["geometry"], i, self.fraction),
537
+ )
538
+ for i, r in gdf.iterrows()
539
+ ]
540
+
541
+ n = len(gdf)
542
+
543
+ for job in tqdm(jobs, total=n, desc="Reducing points in polygons"):
544
+ geom, idx = job.get()
545
+ gdf.loc[idx, "geometry"] = geom
546
+
547
+ time["rdp"] = perf_counter() - start_time
548
+
549
+ gdf = self._make_gpd_valid(gdf)
550
+
551
+ print("[8/9] Removing intersections again...")
552
+ start_time = perf_counter()
553
+ gdf = make_multi_polygons(gdf, "label")
554
+ gdf = self._remove_intersections(gdf)
555
+ time["remove-intersections-2"] = perf_counter() - start_time
556
+
557
+ gdf = gdf.explode(index_parts=False).reset_index(drop=True)
558
+ gdf = self._make_gpd_valid(gdf)
559
+
560
+ print("[9/9] Filling RDP gaps...")
561
+ start_time = perf_counter()
562
+ gdf = self._fill_rdp_gaps(gdf, self.interior_max_area)
563
+ time["fill-rdp-gaps"] = perf_counter() - start_time
564
+ gdf = self._make_gpd_valid(gdf)
565
+
566
+ self.output_gdf = gdf
567
+ self.time = time
568
+
569
+ total_time = sum(time.values())
570
+ print(f"\nTotal time: {total_time:.2f} seconds")
571
+ return gdf
@@ -266,7 +266,7 @@ def train(
266
266
 
267
267
  # Delete the temp directory.
268
268
  shutil.rmtree(save_dir)
269
- return None
269
+ return trainer, None
270
270
  else:
271
271
  print("Starting training...")
272
272
  _ = trainer.train()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dsa_helpers
3
- Version: 1.1.19.dev2
3
+ Version: 2.0.0
4
4
  Summary: Utility functions for working with the DSA girder client.
5
5
  Author-email: "Juan C. Vizcarra" <jvizcar@emory.edu>
6
6
  Maintainer-email: "Juan C. Vizcarra" <jvizcar@emory.edu>
@@ -29,6 +29,7 @@ Requires-Dist: large-image
29
29
  Requires-Dist: rasterio
30
30
  Requires-Dist: colorama
31
31
  Requires-Dist: albumentations
32
+ Requires-Dist: rdp
32
33
  Provides-Extra: opencv
33
34
  Requires-Dist: opencv-python; extra == "opencv"
34
35
  Provides-Extra: opencv-headless
@@ -16,6 +16,7 @@ large-image
16
16
  rasterio
17
17
  colorama
18
18
  albumentations
19
+ rdp
19
20
 
20
21
  [all]
21
22
  opencv-python
@@ -23,7 +23,8 @@ dependencies = [
23
23
  "large-image",
24
24
  "rasterio",
25
25
  "colorama",
26
- "albumentations"
26
+ "albumentations",
27
+ "rdp"
27
28
  ]
28
29
 
29
30
  name = "dsa_helpers"
@@ -41,7 +42,7 @@ classifiers = [
41
42
  "License :: OSI Approved :: MIT License",
42
43
  ]
43
44
  keywords = ["dsa", "girder", "girder_client"]
44
- version = "v1.1.19dev2"
45
+ version = "v2.0.0"
45
46
 
46
47
  [project.optional-dependencies]
47
48
  opencv = ["opencv-python"]
@@ -1,231 +0,0 @@
1
- import torch
2
- from large_image_eager_iterator import LargeImagePrefetch
3
- from transformers import (
4
- SegformerForSemanticSegmentation,
5
- SegformerImageProcessor,
6
- )
7
- from PIL import Image
8
- from large_image import getTileSource
9
- import geopandas as gpd
10
- from shapely.affinity import scale
11
- import pandas as pd
12
- import histomicstk as htk
13
- import numpy as np
14
-
15
- from ...image_utils import label_mask_to_polygons
16
- from ...gpd_utils import remove_gdf_overlaps
17
-
18
- stain_color_map = htk.preprocessing.color_deconvolution.stain_color_map
19
-
20
- # specify stains of input image
21
- stains = [
22
- "hematoxylin", # nuclei stain
23
- "eosin", # cytoplasm stain
24
- "null",
25
- ] # set to null if input contains only two stains
26
-
27
- # create stain matrix
28
- W = np.array([stain_color_map[st] for st in stains]).T
29
-
30
-
31
- def inference(
32
- model: str | torch.nn.Module,
33
- wsi_fp: str,
34
- batch_size: int = 16,
35
- tile_size: int = 512,
36
- mag: float | None = None,
37
- workers: int = 8,
38
- chunk_mult: int = 2,
39
- prefetch: int = 2,
40
- device: str | None = None,
41
- tolerance: float | None = 2.0,
42
- min_area: float = 1000,
43
- hematoxylin_channel: bool = False,
44
- ) -> gpd.GeoDataFrame:
45
- """Inference using SegFormer semantic segmentation model on a WSI.
46
-
47
- Args:
48
- model (str | torch.nn.Module): Path to the model checkpoint or
49
- a pre-loaded model.
50
- wsi_fp (str): File path to the WSI.
51
- batch_size (int, optional): Batch size for inference. Defaults
52
- to 16.
53
- tile_size (int, optional): Tile size for inference. Defaults to
54
- 512.
55
- mag (float, optional): Magnification for inference. Defaults to
56
- None, which will use the scan magnification of WSI.
57
- workers (int, optional): Number of workers for inference.
58
- Defaults to 8.
59
- chunk_mult (int, optional): Chunk multiplier for inference.
60
- Defaults to 2.
61
- prefetch (int, optional): Number of prefetch for inference.
62
- Defaults to 2.
63
- device (str, optional): Device for inference. Default is None,
64
- will use "gpu" if available, otherwise "cpu".
65
- tolerance (float | None, optional): Tolerance for simplification
66
- of the predicted polygons. Guidelines: 0.5 for mild
67
- simplification, 1.0 for moderate, 2.0 for more aggressive.
68
- If set to None or 0.0, the polygons will not be simplified.
69
- min_area (float, optional): Minimum area of a polygon to be
70
- simplified. This is added to avoid simplifying small
71
- polygons that would probably lose too much information.
72
- Defaults to 1000.
73
- hematoxylin_channel (bool, optional): Whether to use the
74
- hematoxylin channel when predicting the segmentation mask.
75
- Defaults to False.
76
-
77
- Returns:
78
- gpd.GeoDataFrame: A GeoDataFrame containing the predicted
79
- polygons and labels.
80
-
81
- """
82
- # Initiate the tile iterator.
83
- iterator = LargeImagePrefetch(
84
- wsi_fp,
85
- batch=batch_size,
86
- tile_size=(tile_size, tile_size),
87
- scale_mode="mag",
88
- target_scale=mag,
89
- workers=workers,
90
- chunk_mult=chunk_mult,
91
- prefetch=prefetch,
92
- nchw=False,
93
- icc=True,
94
- )
95
-
96
- if device is None:
97
- device = "cuda" if torch.cuda.is_available() else "cpu"
98
- print(f"Using device: {device}")
99
-
100
- device = torch.device(device)
101
-
102
- # Load the model.
103
- if isinstance(model, str):
104
- model = SegformerForSemanticSegmentation.from_pretrained(
105
- model, local_files_only=True, device_map=device
106
- )
107
-
108
- model.eval()
109
-
110
- # Iterate through batches.
111
- batch_n = 0
112
-
113
- # Image processor for images.
114
- processor = SegformerImageProcessor()
115
-
116
- # Track all predicted polygons.
117
- wsi_polygons = []
118
-
119
- # Scaling factor, multiply to go from scan magnification to desired mag.
120
- ts_metadata = getTileSource(wsi_fp).getMetadata()
121
- scan_mag = ts_metadata["magnification"]
122
-
123
- if mag is None:
124
- mag = scan_mag
125
- sf = 1.0
126
- else:
127
- sf = mag / scan_mag
128
-
129
- for batch in iterator:
130
- # Get the batch of images.
131
- imgs = batch[0].view() # returns a numpy array of shape (N, H, W, C)
132
- coordinates = batch[1]
133
-
134
- if hematoxylin_channel:
135
- img_list = []
136
-
137
- for img in imgs:
138
- img = (
139
- htk.preprocessing.color_deconvolution.color_deconvolution(
140
- img, W
141
- ).Stains[:, :, 0]
142
- )
143
- img = np.stack([img, img, img], axis=-1)
144
- img_list.append(img)
145
-
146
- imgs = img_list
147
-
148
- # Convert the numpy arrays to PIL images.
149
- imgs = [Image.fromarray(img) for img in imgs]
150
-
151
- # Pass the images through the processor.
152
- inputs = processor(imgs, return_tensors="pt")
153
- inputs = inputs.to(model.device)
154
-
155
- # Predict on the batch.
156
- with torch.no_grad():
157
- output = model(inputs["pixel_values"])
158
- logits = output.logits
159
-
160
- # Get the logits out, resizing them to the original tile size.
161
- logits = torch.nn.functional.interpolate(
162
- logits,
163
- size=tile_size,
164
- mode="bilinear",
165
- )
166
-
167
- # Get predicted class labels for each pixel.
168
- masks = torch.argmax(logits, dim=1).detach().cpu().numpy()
169
-
170
- # Loop through each mask to extract the contours as shapely polygons.
171
- for i, mask in enumerate(masks):
172
- img_metadata = coordinates[i]
173
- x, y = img_metadata[6], img_metadata[4]
174
- x = int(x * sf)
175
- y = int(y * sf)
176
-
177
- polygon_and_labels = label_mask_to_polygons(
178
- mask,
179
- x_offset=x,
180
- y_offset=y,
181
- )
182
-
183
- for polygon_and_label in polygon_and_labels:
184
- polygon, label = polygon_and_label
185
- label = int(label)
186
-
187
- # Do something with the polygon and label.
188
- wsi_polygons.append([polygon, label])
189
-
190
- batch_n += 1
191
- print(f"\r Processed batch {batch_n}. ", end="")
192
- print()
193
-
194
- # Convert polygons and labels to a GeoDataFrame.
195
- gdf = gpd.GeoDataFrame(wsi_polygons, columns=["geometry", "label"])
196
- gdf["geometry"] = gdf["geometry"].buffer(1)
197
- gdf = gdf.dissolve(by="label", as_index=False)
198
- gdf = gdf.explode(index_parts=False).reset_index(drop=True)
199
-
200
- if tolerance:
201
- # Add the area column.
202
- gdf["area"] = gdf["geometry"].area
203
-
204
- # Split the dataframe into those polygons that need to be simplified and those that do not.
205
- gdf_to_simplify = gdf[gdf["area"] > min_area].reset_index(drop=True)
206
- gdf_not_to_simplify = gdf[gdf["area"] <= min_area].reset_index(
207
- drop=True
208
- )
209
-
210
- # Simplify the polygons that need to be simplified.
211
- gdf_to_simplify["geometry"] = gdf_to_simplify["geometry"].simplify(
212
- tolerance=tolerance, preserve_topology=True
213
- )
214
-
215
- # Concatenate the simplified polygons with the polygons that do not need to be simplified.
216
- gdf = pd.concat(
217
- [gdf_to_simplify, gdf_not_to_simplify], ignore_index=True
218
- )
219
-
220
- # Remove overlapping polygons.
221
- no_overlap_gdf = remove_gdf_overlaps(
222
- gdf,
223
- columns="label",
224
- )
225
-
226
- # Scale the polygon to scan magnification.
227
- no_overlap_gdf["geometry"] = no_overlap_gdf["geometry"].apply(
228
- lambda geom: scale(geom, xfact=1 / sf, yfact=1 / sf, origin=(0, 0))
229
- )
230
-
231
- return no_overlap_gdf
File without changes
File without changes
File without changes
File without changes
File without changes