organoid-analyzer 0.1.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.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.3
2
+ Name: organoid-analyzer
3
+ Version: 0.1.0
4
+ Summary: Analyze brightfield images of organoids
5
+ Author: Jian Wei Tay
6
+ Author-email: Jian Wei Tay <jian.tay@vai.org>
7
+ Requires-Dist: matplotlib>=3.11.0
8
+ Requires-Dist: numpy>=2.5.0
9
+ Requires-Dist: scikit-image>=0.26.0
10
+ Requires-Dist: scipy>=1.18.0
11
+ Requires-Dist: tqdm>=4.68.3
12
+ Requires-Python: >=3.14
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Organoid analyzer
16
+
17
+ A Python tool for analyzing brightfield images of organoids.
18
+
19
+ ## Usage
20
+
21
+ ### Setup and installation
22
+
23
+ You can install the library directly either from PyPi or from this repository.
24
+
25
+ ```bash
26
+ pip install organoid-analyzer
27
+ pip install "organoid-analyzer @ git+https://github.com/vaioic/organoid-analyzer.git@main"
28
+ ```
29
+
30
+ If you need the latest bleeding-edge version (which likely contains bugs and other incomplete code)
31
+
32
+ ```bash
33
+ pip install "organoid-analyzer @ git+https://github.com/vaioic/organoid-analyzer.git@dev"
34
+ ```
35
+
36
+
37
+ ## Development
38
+
39
+ ### Using uv (Recommended)
40
+
41
+ This project uses [uv](https://docs.astral.sh/uv/) to manage the development environment.
42
+
43
+ 1. Install ``uv``
44
+ * **macOS or Linux:** ``curl -LsSf https://astral.sh/uv/install.sh | sh``
45
+ * **Windows:** ``powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"``
46
+
47
+ To check if you have ``uv`` installed, open a terminal and run ``uv --version``.
48
+
49
+ 2. Clone the repository
50
+ ```bash
51
+ git clone git@github.com:vaioic/brightfield-organoid-analyzer.git
52
+ cd brightfield-organoid-analyzer
53
+ ```
54
+
55
+ 3. Sync the environment (this will setup the correct virtual environment and dependencies)
56
+ ```bash
57
+ uv sync
58
+ ```
59
+
60
+ 3. Link this toolbox in editable mode in your analysis project
61
+ ```bash
62
+ uv add --editable "path/to/brightfield-organoid-analyzer"
63
+ ```
64
+ Note: You should change this to the published version when you are done.
65
+
66
+
67
+ ### Code style and testing
68
+
69
+ This project also uses ``ruff`` for ultra-fast linting and code formatting, and ``pytest`` for unit tests.
70
+
71
+ ```bash
72
+ # Run linting checks
73
+ uv run ruff check
74
+
75
+ # Auto-format codebase
76
+ uv run ruff format
77
+
78
+ # Run test suite
79
+ uv run pytest
80
+ ```
81
+
82
+ ## Issues
83
+
84
+ If you encounter any issues with running the code or have any questions, please create an [Issue](https://github.com/vaioic/brightfield-organoid-analyzer/issues) or send an email to opticalimaging@vai.org. If you are reporting a bug, please include any error messages to aid with troubleshooting.
85
+
86
+ ## License
87
+
88
+ This project is licensed under the GPLv3 License. See the [LICENSE](LICENSE) file for details.
89
+
90
+ ## Citing & Acknowledgements
91
+
92
+ This repository is publicly available for open-source use, but it is developed and maintained by the Optical Imaging Core at the Van Andel Institute. If code from this repository contributed to data used in a publication, abstract, or presentation, please cite and acknowledge our work based on your affiliation:
93
+
94
+ ### For External Users
95
+ Please cite this repository and acknowledge the author(s) in your publication's materials, methods, or acknowledgements section:
96
+ > "Image analysis pipelines were adapted from open-source tools developed by the Optical Imaging Core at the Van Andel Institute (GitHub:[brightfield-organoid-analyzer](https://github.com/vaioic/brightfield-organoid-analyzer))."
97
+
98
+ If you require custom adjustments or advanced analysis support, please contact us at opticalimaging@vai.org.
99
+
100
+ ### For Internal Users & Close Collaborators
101
+ If you are an internal researcher or an external collaborator working directly with our staff, please include our Research Resource Identifier (RRID) in your materials and methods section:
102
+ > "Image analysis and data processing were performed in collaboration with the Optical Imaging Core at the Van Andel Institute (RRID:SCR_021968)."
103
+
104
+ Please review the Acknowledgement and Authorship Guidelines on [VAI's Core Technology and Services website](https://vanandelinstitute.sharepoint.com/sites/Cores/SitePages/Acknowledgements-and-Authorship.aspx)
105
+
106
+ ### Contributors
107
+ <a href="https://github.com/vaioic/brightfield-organoid-analyzer/graphs/contributors">
108
+ <img src="https://contrib.rocks/image?repo=vaioic/brightfield-organoid-analyzer" />
109
+ </a>
110
+
111
+ ## Changelog
112
+
113
+ ### v0.1.0 (2026-07-03)
114
+ * Adapted code into a toolbox.
115
+ * Test that code works on EB cells [[OIC-334](https://varioic.atlassian.net/browse/OIC-334)]
@@ -0,0 +1,101 @@
1
+ # Organoid analyzer
2
+
3
+ A Python tool for analyzing brightfield images of organoids.
4
+
5
+ ## Usage
6
+
7
+ ### Setup and installation
8
+
9
+ You can install the library directly either from PyPi or from this repository.
10
+
11
+ ```bash
12
+ pip install organoid-analyzer
13
+ pip install "organoid-analyzer @ git+https://github.com/vaioic/organoid-analyzer.git@main"
14
+ ```
15
+
16
+ If you need the latest bleeding-edge version (which likely contains bugs and other incomplete code)
17
+
18
+ ```bash
19
+ pip install "organoid-analyzer @ git+https://github.com/vaioic/organoid-analyzer.git@dev"
20
+ ```
21
+
22
+
23
+ ## Development
24
+
25
+ ### Using uv (Recommended)
26
+
27
+ This project uses [uv](https://docs.astral.sh/uv/) to manage the development environment.
28
+
29
+ 1. Install ``uv``
30
+ * **macOS or Linux:** ``curl -LsSf https://astral.sh/uv/install.sh | sh``
31
+ * **Windows:** ``powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"``
32
+
33
+ To check if you have ``uv`` installed, open a terminal and run ``uv --version``.
34
+
35
+ 2. Clone the repository
36
+ ```bash
37
+ git clone git@github.com:vaioic/brightfield-organoid-analyzer.git
38
+ cd brightfield-organoid-analyzer
39
+ ```
40
+
41
+ 3. Sync the environment (this will setup the correct virtual environment and dependencies)
42
+ ```bash
43
+ uv sync
44
+ ```
45
+
46
+ 3. Link this toolbox in editable mode in your analysis project
47
+ ```bash
48
+ uv add --editable "path/to/brightfield-organoid-analyzer"
49
+ ```
50
+ Note: You should change this to the published version when you are done.
51
+
52
+
53
+ ### Code style and testing
54
+
55
+ This project also uses ``ruff`` for ultra-fast linting and code formatting, and ``pytest`` for unit tests.
56
+
57
+ ```bash
58
+ # Run linting checks
59
+ uv run ruff check
60
+
61
+ # Auto-format codebase
62
+ uv run ruff format
63
+
64
+ # Run test suite
65
+ uv run pytest
66
+ ```
67
+
68
+ ## Issues
69
+
70
+ If you encounter any issues with running the code or have any questions, please create an [Issue](https://github.com/vaioic/brightfield-organoid-analyzer/issues) or send an email to opticalimaging@vai.org. If you are reporting a bug, please include any error messages to aid with troubleshooting.
71
+
72
+ ## License
73
+
74
+ This project is licensed under the GPLv3 License. See the [LICENSE](LICENSE) file for details.
75
+
76
+ ## Citing & Acknowledgements
77
+
78
+ This repository is publicly available for open-source use, but it is developed and maintained by the Optical Imaging Core at the Van Andel Institute. If code from this repository contributed to data used in a publication, abstract, or presentation, please cite and acknowledge our work based on your affiliation:
79
+
80
+ ### For External Users
81
+ Please cite this repository and acknowledge the author(s) in your publication's materials, methods, or acknowledgements section:
82
+ > "Image analysis pipelines were adapted from open-source tools developed by the Optical Imaging Core at the Van Andel Institute (GitHub:[brightfield-organoid-analyzer](https://github.com/vaioic/brightfield-organoid-analyzer))."
83
+
84
+ If you require custom adjustments or advanced analysis support, please contact us at opticalimaging@vai.org.
85
+
86
+ ### For Internal Users & Close Collaborators
87
+ If you are an internal researcher or an external collaborator working directly with our staff, please include our Research Resource Identifier (RRID) in your materials and methods section:
88
+ > "Image analysis and data processing were performed in collaboration with the Optical Imaging Core at the Van Andel Institute (RRID:SCR_021968)."
89
+
90
+ Please review the Acknowledgement and Authorship Guidelines on [VAI's Core Technology and Services website](https://vanandelinstitute.sharepoint.com/sites/Cores/SitePages/Acknowledgements-and-Authorship.aspx)
91
+
92
+ ### Contributors
93
+ <a href="https://github.com/vaioic/brightfield-organoid-analyzer/graphs/contributors">
94
+ <img src="https://contrib.rocks/image?repo=vaioic/brightfield-organoid-analyzer" />
95
+ </a>
96
+
97
+ ## Changelog
98
+
99
+ ### v0.1.0 (2026-07-03)
100
+ * Adapted code into a toolbox.
101
+ * Test that code works on EB cells [[OIC-334](https://varioic.atlassian.net/browse/OIC-334)]
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "organoid-analyzer"
3
+ version = "0.1.0"
4
+ description = "Analyze brightfield images of organoids"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jian Wei Tay", email = "jian.tay@vai.org" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "matplotlib>=3.11.0",
12
+ "numpy>=2.5.0",
13
+ "scikit-image>=0.26.0",
14
+ "scipy>=1.18.0",
15
+ "tqdm>=4.68.3",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.11.24,<0.12.0"]
20
+ build-backend = "uv_build"
@@ -0,0 +1,666 @@
1
+ # Import required packages
2
+ import csv
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import skimage
9
+ from matplotlib import pyplot as plt
10
+
11
+ # from cellpose import models
12
+ from scipy import ndimage as ndi
13
+ from scipy.spatial.distance import pdist, squareform
14
+ from tqdm import tqdm
15
+
16
+
17
+ def process_directory(
18
+ input_dir,
19
+ output_dir,
20
+ file_ext=[".tif"],
21
+ threshold=0.99,
22
+ cell_type="EB",
23
+ spacing=None,
24
+ ):
25
+
26
+ # Validate the inputs
27
+ if isinstance(input_dir, str):
28
+ input_dir = Path(input_dir)
29
+ elif isinstance(input_dir, Path):
30
+ pass
31
+ else:
32
+ raise ValueError(
33
+ f"Expected the first argument to be a str or Path to the input directory. Instead it is a {type(input_dir)}."
34
+ )
35
+
36
+ if isinstance(output_dir, str):
37
+ output_dir = Path(output_dir)
38
+ elif isinstance(output_dir, Path):
39
+ pass
40
+ else:
41
+ raise ValueError(
42
+ f"Expected the second argument to be a str or Path to the output directory. Instead it is a {type(output_dir)}."
43
+ )
44
+
45
+ if not output_dir.exists():
46
+ output_dir.mkdir(parents=True)
47
+ elif output_dir.is_file():
48
+ raise ValueError(
49
+ "Expected the second argument to a path to a directory. Instead it appears to be a file."
50
+ )
51
+
52
+ # Get list of files that match the extension(s)
53
+ all_files = input_dir.rglob("*")
54
+ file_list = []
55
+ for f in all_files:
56
+ if f.suffix in file_ext:
57
+ file_list.append(f.resolve())
58
+
59
+ all_df = []
60
+
61
+ with tqdm(file_list) as pbar:
62
+ for f in pbar:
63
+ pbar.set_description(f"{f.name}")
64
+ df = process_image(
65
+ f,
66
+ output_dir,
67
+ threshold=threshold,
68
+ cell_type=cell_type,
69
+ spacing=spacing,
70
+ pbar=pbar,
71
+ return_df=True,
72
+ )
73
+
74
+ # Add the filename
75
+ df["Image"] = str(f)
76
+ all_df.append(df)
77
+
78
+ # Merge the dataframes and export
79
+ merged_df = pd.concat(all_df, ignore_index=True)
80
+
81
+ export_to_csv(output_dir / "merged.csv", merged_df, spacing=spacing)
82
+
83
+
84
+ def process_image(
85
+ input_path,
86
+ output_dir,
87
+ threshold=0.99,
88
+ cell_type="EB",
89
+ segment_inner=False,
90
+ spacing=None,
91
+ pbar=None,
92
+ return_df=False,
93
+ ):
94
+
95
+ # Validate the inputs
96
+ if isinstance(input_path, str):
97
+ input_path = Path(input_path)
98
+ elif isinstance(input_path, Path):
99
+ pass
100
+ else:
101
+ raise ValueError(
102
+ f"Expected the first argument to be a str or Path to the image file. Instead it is a {type(input_path)}."
103
+ )
104
+
105
+ if isinstance(output_dir, str):
106
+ output_dir = Path(output_dir)
107
+ elif isinstance(output_dir, Path):
108
+ pass
109
+ else:
110
+ raise ValueError(
111
+ f"Expected the second argument to be a str or Path to the output directory. Instead it is a {type(output_dir)}."
112
+ )
113
+
114
+ if not output_dir.exists():
115
+ output_dir.mkdir(parents=True)
116
+ elif output_dir.is_file():
117
+ raise ValueError(
118
+ "Expected the second argument to a path to a directory. Instead it appears to be a file."
119
+ )
120
+
121
+ # Read in image
122
+ image_rgb = skimage.io.imread(input_path)
123
+
124
+ # Segment the cell
125
+ update_status(f"{input_path.name}:Segmenting organoids", pbar)
126
+ match cell_type:
127
+ case "EB":
128
+ if segment_inner:
129
+ labels, inner_cell_labels = segment_cells(
130
+ image_rgb, threshold=threshold, segment_inner=True
131
+ )
132
+ else:
133
+ labels = segment_cells(image_rgb, threshold=threshold)
134
+
135
+ case "ES":
136
+ labels, inner_cell_labels = segment_cells_dark(
137
+ image_rgb, threshold=threshold
138
+ )
139
+
140
+ # Check if labels are empty
141
+ if labels.max() == 0:
142
+ raise ValueError("Segmentation failed: No object labels were detected.")
143
+
144
+ # Measure properties
145
+ update_status(f"{input_path.name}:Measuring properties", pbar)
146
+ props = skimage.measure.regionprops_table(
147
+ labels,
148
+ properties=(
149
+ "label",
150
+ "area",
151
+ "feret_diameter_max",
152
+ "centroid",
153
+ "bbox",
154
+ "image_convex",
155
+ ),
156
+ )
157
+
158
+ # Measure internal properties
159
+ # TODO: This is likely broken
160
+ if segment_inner:
161
+ inner_cell_props = skimage.measure.regionprops(inner_cell_labels)
162
+
163
+ mean_distances = []
164
+
165
+ for p in tqdm(props, leave=False):
166
+ # Calculate the average thickness of bright regions
167
+ curr_cell_mask = np.zeros(labels.shape, dtype=np.bool)
168
+ curr_cell_mask[labels == p["label"]] = True
169
+
170
+ if inner_cell_labels:
171
+ contours = skimage.measure.find_contours(curr_cell_mask)
172
+
173
+ # Return the longest contour
174
+ longest = sorted(contours, key=len, reverse=True)[:1]
175
+ longest = np.array(longest[0], dtype=int)
176
+
177
+ curr_inner_mask = np.zeros(inner_cell_labels.shape, dtype=np.bool)
178
+ curr_inner_mask[inner_cell_labels == p["label"]] = True
179
+
180
+ # Make a mask that leaves only the center region false
181
+ curr_inner_mask_bg_filled = curr_inner_mask + (labels != p["label"])
182
+ curr_inner_mask_bg_filled = skimage.morphology.remove_small_holes(
183
+ curr_inner_mask_bg_filled, max_size=500
184
+ )
185
+
186
+ curr_dist = ndi.distance_transform_edt(curr_inner_mask_bg_filled)
187
+
188
+ mean_distances.append(np.mean(curr_dist[longest[:, 0], longest[:, 1]]))
189
+
190
+ # Save output
191
+ fn = input_path.stem
192
+
193
+ # Convert data to DataFrame
194
+ df = pd.DataFrame(props)
195
+
196
+ # Drop the centroid information
197
+ df = df.drop(
198
+ columns=[
199
+ "centroid-0",
200
+ "centroid-1",
201
+ "bbox-0",
202
+ "bbox-1",
203
+ "bbox-2",
204
+ "bbox-3",
205
+ "image_convex",
206
+ ]
207
+ )
208
+
209
+ export_to_csv(output_dir / (fn + ".csv"), df, spacing=spacing)
210
+
211
+ fig = make_labeled_image(image_rgb, labels, props)
212
+
213
+ fig.savefig(output_dir / (fn + "_labels.png"))
214
+
215
+ update_status(f"{input_path.name}:Data written to {output_dir}.", pbar)
216
+
217
+ if return_df:
218
+ return df
219
+
220
+
221
+ def update_status(msg, pbar=None):
222
+
223
+ if pbar is not None:
224
+ tqdm.write(msg)
225
+ else:
226
+ print(msg)
227
+
228
+
229
+ # def make_labeled_image(image, labels, props):
230
+
231
+ # fig, ax = plt.subplots(figsize=(10, 10))
232
+
233
+ # overlay = skimage.segmentation.mark_boundaries(
234
+ # image, labels, mode="thick", color=(1, 0, 1)
235
+ # )
236
+
237
+ # ax.imshow(overlay)
238
+
239
+ # for idx in range(len(props["label"])):
240
+ # ax.text(
241
+ # props["centroid-1"][idx],
242
+ # props["centroid-0"][idx],
243
+ # props["label"][idx],
244
+ # fontsize=8,
245
+ # color="yellow",
246
+ # fontweight="bold",
247
+ # ha="center",
248
+ # va="center",
249
+ # )
250
+
251
+ # return fig
252
+
253
+
254
+ def make_labeled_image(image, labels, props):
255
+
256
+ fig, ax = plt.subplots(figsize=(10, 10))
257
+
258
+ overlay = skimage.segmentation.mark_boundaries(
259
+ image, labels, mode="thick", color=(1, 0, 1)
260
+ )
261
+
262
+ ax.imshow(overlay)
263
+
264
+ for idx in range(len(props["label"])):
265
+ ax.text(
266
+ props["centroid-1"][idx],
267
+ props["centroid-0"][idx],
268
+ props["label"][idx],
269
+ fontsize=8,
270
+ color="yellow",
271
+ fontweight="bold",
272
+ ha="center",
273
+ va="center",
274
+ )
275
+
276
+ # ---Generated by Gemini---
277
+ # Plot the max feret diameter
278
+ padded_hull = np.pad(
279
+ props["image_convex"][idx], 2, mode="constant", constant_values=0
280
+ )
281
+
282
+ contours = skimage.measure.find_contours(
283
+ padded_hull, 0.5, fully_connected="high"
284
+ )
285
+ if not contours:
286
+ continue
287
+ coords = np.vstack(contours)
288
+
289
+ distance_matrix = squareform(pdist(coords, "euclidean"))
290
+ idx1, idx2 = np.unravel_index(np.argmax(distance_matrix), distance_matrix.shape)
291
+
292
+ pt1_local = coords[idx1]
293
+ pt2_local = coords[idx2]
294
+
295
+ min_row = props["bbox-0"][idx]
296
+ min_col = props["bbox-1"][idx]
297
+
298
+ pt1_global_y = pt1_local[0] + min_row - 2
299
+ pt1_global_x = pt1_local[1] + min_col - 2
300
+ pt2_global_y = pt2_local[0] + min_row - 2
301
+ pt2_global_x = pt2_local[1] + min_col - 2
302
+
303
+ ax.plot(
304
+ [pt1_global_x, pt2_global_x],
305
+ [pt1_global_y, pt2_global_y],
306
+ color="cyan",
307
+ linestyle="--",
308
+ linewidth=1.2,
309
+ marker="o",
310
+ markersize=3,
311
+ )
312
+ # --- end ---
313
+
314
+ return fig
315
+
316
+
317
+ def export_to_csv(output_file, data, spacing=None):
318
+ """
319
+ Write data to csv.
320
+
321
+ The function will use human-readable column names and convert values from pixels to
322
+ microns (if spacing is given).
323
+
324
+ Parameters
325
+ ----------
326
+ output_file : Path
327
+ Path to output file
328
+ data : DataFrame
329
+ Output from regionprops_table, converted into a DataFrame
330
+ spacing : float, optional
331
+ Scaling in microns per pixel, by default None. If None, the values in pixels
332
+ will be returned.
333
+ """
334
+
335
+ # Define human-readable headers for the CSV export
336
+ header_map = {
337
+ "label": "Object ID",
338
+ "area": "Area (px)",
339
+ "area_microns": "Area (micron2)",
340
+ "feret_diameter_max": "Feret diameter (px)",
341
+ "feret_diameter_max_microns": "Feret diameter (micron)",
342
+ }
343
+
344
+ # Convert to microns if spacing exists
345
+ if spacing:
346
+ if "area" in data.columns:
347
+ data["area_microns"] = data["area"] * (spacing**2)
348
+
349
+ if "feret_diameter_max" in data.columns:
350
+ data["feret_diameter_max_microns"] = data["feret_diameter_max"] * spacing
351
+
352
+ # Rename the columns
353
+ data = data.rename(columns=header_map)
354
+
355
+ # Move the label column to the left
356
+ leading_cols = ["Object ID"]
357
+ if "Image" in data.columns:
358
+ leading_cols = ["Image"] + leading_cols
359
+
360
+ data = data[leading_cols + [col for col in data.columns if col not in leading_cols]]
361
+
362
+ data.to_csv(output_file, index=False)
363
+
364
+
365
+ def segment_cells(
366
+ image, threshold=0.99, min_size=75, segment_inner=False, debug_plot=False
367
+ ):
368
+ """
369
+ Segment organoids.
370
+
371
+ This function uses intensity thresholding to identify organoids. The threshold is
372
+ determined by threshold * max(image). In other words, we assume that the organoids
373
+ are dark against a bright (white) background.
374
+
375
+ The watershed parameters are calculated automatically. The absolute threshold is
376
+ determined by the size of the objects. The parameter min_size is used to control how
377
+ close the seed points are to each other, which affects the minimum size each object
378
+ can be.
379
+
380
+ Additionally, the function also tries to remove outlier objects which are too big
381
+ (undersegmented) or too small (oversegmented) compared to the average size of the
382
+ objects. This is defined as objects which have a size greater than mean + 7 * the stdev of
383
+ the size.
384
+
385
+ Parameters
386
+ ----------
387
+ image : ndarray
388
+ Input image. If image is RGB, it will be converted to gray.
389
+ threshold : float, optional
390
+ Threshold factor, by default 0.99. A higher threshold factor will result in more
391
+ pixels being labeled True.
392
+ min_size : float, optional
393
+ Minimum distance between watershed seed points, which translates to minimum
394
+ object size. If processing a large batch, set this to the smallest expected
395
+ object size.
396
+ segment_inner : bool, optional
397
+ If True, will also segment the internal dark region of the organoid. By default False.
398
+ debug_plot : bool, optional
399
+ If True, will generate plots to optimize segmentation parameters, by default False
400
+
401
+ Returns
402
+ -------
403
+ label : ndarray
404
+ Object labels
405
+ inner_labels : ndarray, optional
406
+ Labels of the inner regions. The label values match the object labels. This is
407
+ only returned if segment_inner is True.
408
+ """
409
+
410
+ # Check if image is RGB
411
+ if len(image.shape) == 2:
412
+ pass # Image is grayscale
413
+ elif len(image.shape) == 3:
414
+ if image.shape[-1] == 3:
415
+ image = skimage.color.rgb2gray(image)
416
+ else:
417
+ raise ValueError(
418
+ f"Expected the image to be RGB. Instead it has {image.shape[-1]} channels."
419
+ )
420
+ else:
421
+ raise ValueError(
422
+ f"Expected image to have shape H x W (grayscale) or H x W x 3 (RGB). Instead its shape was {image.shape}"
423
+ )
424
+
425
+ # Threshold the image as a percentage of the maximum
426
+ mask = image < (threshold * np.max(image))
427
+
428
+ mask = skimage.morphology.opening(mask, skimage.morphology.disk(30))
429
+ mask = skimage.morphology.remove_small_holes(mask, max_size=200)
430
+
431
+ # Watershed
432
+ distance = ndi.distance_transform_edt(mask)
433
+ coords = skimage.feature.peak_local_max(
434
+ distance,
435
+ footprint=np.ones((3, 3)),
436
+ labels=mask,
437
+ threshold_abs=(0.5 * np.max(distance)),
438
+ min_distance=min_size,
439
+ )
440
+
441
+ mask_marker = np.zeros(distance.shape, dtype=bool)
442
+ mask_marker[tuple(coords.T)] = True
443
+ markers, _ = ndi.label(mask_marker)
444
+ labels = skimage.segmentation.watershed(-distance, markers, mask=mask)
445
+
446
+ labels = skimage.segmentation.clear_border(labels)
447
+
448
+ # Remove objects which are too big/small
449
+ props = skimage.measure.regionprops_table(labels, properties=("area",))
450
+ mean_area = np.mean(props["area"])
451
+ stdev_area = np.std(props["area"])
452
+
453
+ min_area = mean_area - (7 * stdev_area)
454
+
455
+ labels = skimage.morphology.remove_small_objects(labels, max_size=min_area)
456
+
457
+ # Make debug plots
458
+ if debug_plot:
459
+ fig, axes = plt.subplots(2, 2, figsize=(10, 10))
460
+
461
+ axes[0, 0].imshow(image, cmap="gray")
462
+ axes[0, 0].set_title("Input image (grayscale)")
463
+
464
+ ov_mask = skimage.segmentation.mark_boundaries(
465
+ image, mask, mode="thick", color=(0, 1, 0)
466
+ )
467
+
468
+ axes[0, 1].imshow(ov_mask)
469
+ axes[0, 1].set_title("Mask overlay")
470
+
471
+ ov_labels = skimage.segmentation.mark_boundaries(
472
+ image, labels, mode="thick", color=(1, 0, 1)
473
+ )
474
+ axes[1, 0].imshow(ov_labels)
475
+ axes[1, 0].set_title("Label overlay")
476
+
477
+ plt.show()
478
+
479
+ if segment_inner:
480
+ # Do a global threshold to determine dark/light threshold
481
+ thresh_cell = skimage.filters.threshold_otsu(image[labels > 0])
482
+
483
+ inner_cell_mask = image >= thresh_cell
484
+
485
+ inner_cell_labels = labels.copy()
486
+ inner_cell_labels[~inner_cell_mask] = 0
487
+
488
+ return (labels, inner_cell_labels)
489
+
490
+ else:
491
+ return labels
492
+
493
+
494
+ def segment_cells_dark(image, thresh=0.99):
495
+ # This is used for the "ES" cells
496
+
497
+ mask = image > (thresh * np.max(image))
498
+
499
+ # plt.imshow(mask)
500
+ # plt.show()
501
+
502
+ # exit()
503
+
504
+ # mask = skimage.morphology.opening(mask, skimage.morphology.disk(30))
505
+ mask = skimage.morphology.remove_small_holes(mask, max_size=100000)
506
+ mask = skimage.morphology.opening(mask, skimage.morphology.disk(5))
507
+
508
+ # Watershed
509
+ distance = ndi.distance_transform_edt(mask)
510
+ coords = skimage.feature.peak_local_max(
511
+ distance,
512
+ footprint=np.ones((3, 3)),
513
+ labels=mask,
514
+ threshold_abs=(0.3 * np.max(distance)),
515
+ min_distance=75,
516
+ )
517
+
518
+ mask_marker = np.zeros(distance.shape, dtype=bool)
519
+ mask_marker[tuple(coords.T)] = True
520
+
521
+ # # To debug watershed
522
+ # output_test = imoverlay(image, mask, color=[0, 1, 0, 0.4], plot_outlines=False)
523
+
524
+ # plt.imshow(output_test)
525
+ # plt.plot(coords[:, 1], coords[:, 0], 'rx')
526
+ # plt.show()
527
+ # exit()
528
+
529
+ markers, _ = ndi.label(mask_marker)
530
+ labels = skimage.segmentation.watershed(-distance, markers, mask=mask)
531
+ labels = skimage.segmentation.clear_border(labels)
532
+
533
+ # Decide size cutoff
534
+ props = skimage.measure.regionprops_table(labels, properties=("area",))
535
+ mean_area = np.mean(props["area"])
536
+ stdev_area = np.std(props["area"])
537
+
538
+ min_area = mean_area - (3 * stdev_area)
539
+ # print(min_area)
540
+ # print(mean_area)
541
+
542
+ labels = skimage.morphology.remove_small_objects(labels, max_size=min_area)
543
+
544
+ # output_test = imoverlay(image, labels, color=[0, 1, 0, 0.4], plot_outlines=False)
545
+
546
+ # plt.imshow(output_test)
547
+ # plt.show()
548
+ # exit()
549
+
550
+ # # Do a global threshold to determine dark/light threshold
551
+ # thresh_cell = skimage.filters.threshold_otsu(image[labels > 0])
552
+
553
+ # inner_cell_mask = image >= thresh_cell
554
+
555
+ # inner_cell_labels = labels.copy()
556
+ # inner_cell_labels[~inner_cell_mask] = 0
557
+
558
+ return (labels, None)
559
+
560
+
561
+ # Define function to generate overlay images
562
+ def imoverlay(image_A, image_B, color, plot_outlines=True, normalize=True):
563
+ # Always assume that image_A is supposed to be an image
564
+ # Image_B can be an image, binary mask, or label
565
+
566
+ # if normalize:
567
+ # if image_A.ndims == 1:
568
+ # image_A =
569
+ # for c in range(image_A)
570
+
571
+ if plot_outlines and (image_B.ndim == 2):
572
+ image_B = skimage.segmentation.find_boundaries(image_B)
573
+ else:
574
+ image_B = image_B > 0
575
+ # plt.imshow(outlines)
576
+
577
+ image_out = np.zeros((image_A.shape[0], image_A.shape[1], 3), np.uint8)
578
+
579
+ for c in range(3):
580
+ if image_A.ndim < 3:
581
+ curr_slice = (
582
+ (image_A - np.min(image_A)) / (np.max(image_A) - np.min(image_A)) * 255
583
+ )
584
+ else:
585
+ curr_slice = image_A[:, :, c]
586
+
587
+ if len(color) < 4:
588
+ alpha = 1
589
+ else:
590
+ alpha = color[3]
591
+
592
+ curr_slice[image_B] = (color[c] * 255 * alpha) + (
593
+ (1 - alpha) * curr_slice[image_B]
594
+ )
595
+ image_out[:, :, c] = curr_slice
596
+
597
+ return image_out
598
+
599
+
600
+ def dev_test_cp(input_path, output_dir):
601
+
602
+ if isinstance(input_path, str):
603
+ input_path = Path(input_path)
604
+
605
+ if isinstance(output_dir, str):
606
+ output_dir = Path(output_dir)
607
+
608
+ if not output_dir.exists():
609
+ output_dir.mkdir(parents=True)
610
+
611
+ if input_path.is_file():
612
+ file_list = [input_path.resolve]
613
+ else:
614
+ file_list = list(input_path.glob("*.tif"))
615
+
616
+ # imgs should be a list of images
617
+ imgs = [skimage.io.imread(f) for f in file_list]
618
+
619
+ # img = skimage.io.imread(input_path)
620
+
621
+ model = models.CellposeModel(gpu=True) # Runs cellpose sam
622
+ masks, _, _ = model.eval(imgs, diameter=50)
623
+
624
+ # Watershed and save the images
625
+ for idx, mask in enumerate(masks):
626
+ distance = ndi.distance_transform_edt(mask)
627
+ coords = skimage.feature.peak_local_max(
628
+ distance,
629
+ footprint=np.ones((3, 3)),
630
+ labels=mask,
631
+ threshold_abs=(0.3 * np.max(distance)),
632
+ min_distance=75,
633
+ )
634
+
635
+ mask_marker = np.zeros(distance.shape, dtype=bool)
636
+ mask_marker[tuple(coords.T)] = True
637
+
638
+ markers, _ = ndi.label(mask_marker)
639
+ labels = skimage.segmentation.watershed(-distance, markers, mask=mask)
640
+ labels = skimage.segmentation.clear_border(labels)
641
+
642
+ output_test = imoverlay(
643
+ imgs[idx], labels, color=[0, 1, 0, 0.4], plot_outlines=False
644
+ )
645
+
646
+ fn = file_list[idx].stem
647
+
648
+ skimage.io.imsave(output_dir / (fn + ".png"), output_test)
649
+
650
+ cell_props = skimage.measure.regionprops(labels)
651
+
652
+ with open(os.path.join(output_dir, fn + ".csv"), "w", newline="") as file:
653
+ writer = csv.writer(file, delimiter=",")
654
+
655
+ # Write CSV headers
656
+ writer.writerow(["Cell", "Label", "Total area (px)"])
657
+
658
+ ctr = 0
659
+ for p in cell_props:
660
+ writer.writerow([ctr + 1, p.label, p.area])
661
+ ctr += 1
662
+
663
+
664
+ if __name__ == "__main__":
665
+ # TODO: CLI
666
+ pass