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"
|
|
File without changes
|
|
@@ -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
|