Skip to content

Commit 486d29a

Browse files
Copilotlguerard
andcommitted
Implement Cellpose distributed module and fix bugs in processing/trackmate
Co-authored-by: lguerard <6182107+lguerard@users.noreply.github.com> Agent-Logs-Url: https://github.com/imcf/python-imcflibs/sessions/52e63697-e516-4bbc-9f13-96067017fda2
1 parent 26f64c9 commit 486d29a

7 files changed

Lines changed: 328 additions & 7 deletions

File tree

conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
11
"""Pytest configuration."""
22

3+
import sys
4+
from unittest.mock import MagicMock
5+
36
collect_ignore = ["tests/interactive-imagej"]
7+
8+
# Add mock stubs for BIOP Cellpose wrappers not yet covered by imcf-fiji-mocks.
9+
for _mod in [
10+
"ch.epfl.biop.wrappers",
11+
"ch.epfl.biop.wrappers.cellpose",
12+
"ch.epfl.biop.wrappers.cellpose.ij2commands",
13+
]:
14+
if _mod not in sys.modules:
15+
sys.modules[_mod] = MagicMock()

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ sjlogging = ">=0.5.2"
2929
pytest = "^8.0.1"
3030
pytest-cov = "^6.0.0"
3131

32+
[tool.pytest.ini_options]
33+
pythonpath = ["src"]
34+
3235
[build-system]
3336
build-backend = "poetry.core.masonry.api"
3437
requires = ["poetry-core"]

src/imcflibs/imagej/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from . import bioformats
9+
from . import cellpose
910
from . import misc
1011
from . import prefs
1112
from . import projections

src/imcflibs/imagej/cellpose.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""Functions for running [Cellpose] segmentation in Fiji.
2+
3+
This module provides wrappers around the [BIOP Cellpose plugin] for Fiji,
4+
including support for distributed (batch) processing of multiple images.
5+
6+
[Cellpose]: https://cellpose.readthedocs.io/
7+
[BIOP Cellpose plugin]: https://github.com/BIOP/ijl-utilities-wrappers
8+
"""
9+
10+
import os
11+
12+
from ch.epfl.biop.wrappers.cellpose import CellposeTaskSettings
13+
from ch.epfl.biop.wrappers.cellpose.ij2commands import Cellpose_SegmentImgPlusAdvanced
14+
from ij import IJ
15+
16+
from ..log import LOG as log
17+
18+
19+
VALID_MODELS = ["cyto", "cyto2", "cyto3", "nuclei"]
20+
21+
DIMENSION_MODE_2D = "2D"
22+
DIMENSION_MODE_3D = "3D"
23+
24+
25+
def build_cellpose_settings(
26+
model,
27+
diameter,
28+
cyto_channel,
29+
nuclei_channel=0,
30+
cellproba_threshold=0.0,
31+
flow_threshold=0.4,
32+
use_gpu=True,
33+
dimension_mode=DIMENSION_MODE_2D,
34+
stitch_threshold=-1.0,
35+
omni=False,
36+
cluster=False,
37+
additional_flags="",
38+
):
39+
"""Build a Cellpose settings object for use with the BIOP Cellpose plugin.
40+
41+
Parameters
42+
----------
43+
model : str
44+
Name of the Cellpose model to use. Must be one of: cyto, cyto2, cyto3,
45+
nuclei.
46+
diameter : float
47+
Expected diameter of the objects to segment in pixels. Use 0 to let
48+
Cellpose estimate the diameter automatically.
49+
cyto_channel : int
50+
Index of the cytoplasm channel (1-based). Use 0 if not applicable.
51+
nuclei_channel : int, optional
52+
Index of the nuclei channel (1-based). Use 0 to disable the nuclei
53+
channel, by default 0.
54+
cellproba_threshold : float, optional
55+
Cell probability threshold. Lower values result in more detected cells,
56+
by default 0.0.
57+
flow_threshold : float, optional
58+
Flow error threshold for discarding masks. Higher values allow more
59+
errors, by default 0.4.
60+
use_gpu : bool, optional
61+
Whether to use GPU acceleration, by default True.
62+
dimension_mode : str, optional
63+
Dimension mode for segmentation, either ``"2D"`` or ``"3D"``, by
64+
default ``"2D"``.
65+
stitch_threshold : float, optional
66+
Threshold for stitching masks across z-planes. A value of -1.0
67+
disables stitching, by default -1.0.
68+
omni : bool, optional
69+
Whether to use OmniPose segmentation, by default False.
70+
cluster : bool, optional
71+
Whether to use DBSCAN clustering for cell detection, by default False.
72+
additional_flags : str, optional
73+
Additional command-line flags passed to Cellpose, by default ``""``.
74+
75+
Returns
76+
-------
77+
ch.epfl.biop.wrappers.cellpose.CellposeTaskSettings
78+
A configured ``CellposeTaskSettings`` object.
79+
80+
Raises
81+
------
82+
ValueError
83+
If ``model`` is not one of the valid model names.
84+
ValueError
85+
If ``dimension_mode`` is not ``"2D"`` or ``"3D"``.
86+
"""
87+
if model.lower() not in VALID_MODELS:
88+
raise ValueError(
89+
"model '%s' is not valid, must be one of: %s"
90+
% (model, ", ".join(VALID_MODELS))
91+
)
92+
93+
if dimension_mode not in (DIMENSION_MODE_2D, DIMENSION_MODE_3D):
94+
raise ValueError(
95+
"dimension_mode must be '%s' or '%s', got '%s'"
96+
% (DIMENSION_MODE_2D, DIMENSION_MODE_3D, dimension_mode)
97+
)
98+
99+
log.debug(
100+
"Building Cellpose settings: model=%s, diameter=%s, cyto_ch=%s, "
101+
"nuclei_ch=%s, dimension_mode=%s"
102+
% (model, diameter, cyto_channel, nuclei_channel, dimension_mode)
103+
)
104+
105+
settings = CellposeTaskSettings()
106+
settings.setModel(model.lower())
107+
settings.setDiameter(diameter)
108+
settings.setCytoCh(cyto_channel)
109+
settings.setNucleiCh(nuclei_channel)
110+
settings.setCellProbaTreshold(cellproba_threshold)
111+
settings.setFlowThreshold(flow_threshold)
112+
settings.useGpu(use_gpu)
113+
settings.setDimensionMode(dimension_mode)
114+
settings.setStitchThreshold(stitch_threshold)
115+
settings.setOmni(omni)
116+
settings.setCluster(cluster)
117+
settings.setAdditionalFlags(additional_flags)
118+
119+
return settings
120+
121+
122+
def run_cellpose(imp, settings):
123+
"""Run Cellpose segmentation on an ImagePlus.
124+
125+
Parameters
126+
----------
127+
imp : ij.ImagePlus
128+
Input ImagePlus to segment.
129+
settings : ch.epfl.biop.wrappers.cellpose.CellposeTaskSettings
130+
Configured ``CellposeTaskSettings`` object, see
131+
:func:`build_cellpose_settings`.
132+
133+
Returns
134+
-------
135+
ij.ImagePlus
136+
Label image produced by Cellpose, or ``None`` if segmentation fails.
137+
"""
138+
log.info("Running Cellpose on image '%s'" % imp.getTitle())
139+
140+
command = Cellpose_SegmentImgPlusAdvanced()
141+
command.imp = imp
142+
command.cellposeSettings = settings
143+
144+
try:
145+
command.run()
146+
except Exception as err:
147+
log.error("Cellpose segmentation failed: %s" % err)
148+
return None
149+
150+
result = command.output_imp
151+
log.info(
152+
"Cellpose segmentation completed, result image: '%s'" % result.getTitle()
153+
)
154+
155+
return result
156+
157+
158+
def run_cellpose_distributed(imp_list, settings, show_progress=True):
159+
"""Run Cellpose segmentation on a list of ImagePlus objects.
160+
161+
Processes each image sequentially while reporting progress, allowing
162+
Cellpose to be applied across a large batch of images (distributed
163+
processing).
164+
165+
Parameters
166+
----------
167+
imp_list : list(ij.ImagePlus)
168+
List of ImagePlus objects to segment.
169+
settings : ch.epfl.biop.wrappers.cellpose.CellposeTaskSettings
170+
Configured ``CellposeTaskSettings`` object shared across all images,
171+
see :func:`build_cellpose_settings`.
172+
show_progress : bool, optional
173+
Whether to show progress in the ImageJ progress bar, by default True.
174+
175+
Returns
176+
-------
177+
list(ij.ImagePlus)
178+
List of label images produced by Cellpose, with ``None`` entries for
179+
images where segmentation failed.
180+
181+
Example
182+
-------
183+
>>> settings = build_cellpose_settings(
184+
... model="cyto2",
185+
... diameter=30.0,
186+
... cyto_channel=1,
187+
... nuclei_channel=2,
188+
... )
189+
>>> results = run_cellpose_distributed(image_list, settings)
190+
"""
191+
total = len(imp_list)
192+
log.info("Starting distributed Cellpose run on %d images" % total)
193+
194+
results = []
195+
196+
for idx, imp in enumerate(imp_list):
197+
log.info(
198+
"Processing image %d / %d: '%s'" % (idx + 1, total, imp.getTitle())
199+
)
200+
201+
if show_progress:
202+
IJ.showProgress(idx, total)
203+
IJ.showStatus(
204+
"Cellpose: processing image %d / %d" % (idx + 1, total)
205+
)
206+
207+
result = run_cellpose(imp, settings)
208+
results.append(result)
209+
210+
if show_progress:
211+
IJ.showProgress(total, total)
212+
IJ.showStatus("Cellpose: done processing %d images" % total)
213+
214+
failed = sum(1 for r in results if r is None)
215+
if failed:
216+
log.warning(
217+
"Cellpose distributed run finished: %d / %d images failed"
218+
% (failed, total)
219+
)
220+
else:
221+
log.info(
222+
"Cellpose distributed run finished successfully: %d images processed"
223+
% total
224+
)
225+
226+
return results

src/imcflibs/imagej/processing.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def apply_filter(imp, filter_method, filter_radius, do_3d=False):
5353
filter = filter_method + "..."
5454

5555
options = (
56-
"sigma="
56+
"sigma=" + str(filter_radius)
5757
if filter_method == "Gaussian Blur"
5858
else "radius=" + str(filter_radius) + " stack"
5959
)
@@ -85,12 +85,12 @@ def apply_rollingball_bg_subtraction(imp, rolling_ball_radius, do_3d=False):
8585
"""
8686
log.info("Applying rolling ball with radius %d" % rolling_ball_radius)
8787

88-
options = "rolling=" + str(rolling_ball_radius) + " stack" if do_3d else ""
88+
options = "rolling=" + str(rolling_ball_radius) + " stack" if do_3d else "rolling=" + str(rolling_ball_radius)
8989

9090
log.debug("Background subtraction options: %s" % options)
9191

9292
imageplus = imp.duplicate()
93-
IJ.run(imageplus, "Substract Background...", options)
93+
IJ.run(imageplus, "Subtract Background...", options)
9494

9595
return imageplus
9696

@@ -118,7 +118,7 @@ def apply_threshold(imp, threshold_method, do_3d=True):
118118
imageplus = imp.duplicate()
119119

120120
auto_threshold_options = (
121-
threshold_method + " " + "dark" + " " + "stack" if do_3D else ""
121+
threshold_method + " " + "dark" + " " + "stack" if do_3d else threshold_method + " " + "dark"
122122
)
123123

124124
log.debug("Auto threshold options: %s" % auto_threshold_options)

src/imcflibs/imagej/trackmate.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,11 @@ def cellpose_detector(
7777
settings.detectorSettings["OPTIONAL_CHANNEL_2"] = optional_channel
7878

7979
settings.detectorSettings["CELLPOSE_PYTHON_FILEPATH"] = pathtools.join2(
80-
cellpose_env_path, "python.exe"
80+
cellpose_env_path, "python.exe" if os.name == "nt" else "python"
8181
)
82+
home_dir = os.environ.get("USERPROFILE") or os.environ.get("HOME", "")
8283
settings.detectorSettings["CELLPOSE_MODEL_FILEPATH"] = os.path.join(
83-
os.environ["USERPROFILE"], ".cellpose", "models"
84+
home_dir, ".cellpose", "models"
8485
)
8586
input_to_model = {
8687
"nuclei": PretrainedModel.NUCLEI,
@@ -90,7 +91,9 @@ def cellpose_detector(
9091
if model_to_use.lower() in input_to_model:
9192
selected_model = input_to_model[model_to_use.lower()]
9293
else:
93-
print("Selected Model Does Not Exist")
94+
print("Selected model '%s' does not exist, valid options are: %s" % (
95+
model_to_use, ", ".join(input_to_model.keys())
96+
))
9497
return
9598

9699
settings.detectorSettings["CELLPOSE_MODEL"] = selected_model

tests/test_cellpose.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Tests for `imcflibs.imagej.cellpose`."""
2+
3+
import pytest
4+
5+
from imcflibs.imagej.cellpose import (
6+
DIMENSION_MODE_2D,
7+
DIMENSION_MODE_3D,
8+
VALID_MODELS,
9+
build_cellpose_settings,
10+
)
11+
12+
13+
def test_valid_models_list():
14+
"""Test that VALID_MODELS contains the expected model names."""
15+
assert "cyto" in VALID_MODELS
16+
assert "cyto2" in VALID_MODELS
17+
assert "cyto3" in VALID_MODELS
18+
assert "nuclei" in VALID_MODELS
19+
20+
21+
def test_build_cellpose_settings_invalid_model():
22+
"""Test that build_cellpose_settings raises ValueError for invalid model."""
23+
with pytest.raises(ValueError, match="not valid"):
24+
build_cellpose_settings(
25+
model="invalid_model",
26+
diameter=30.0,
27+
cyto_channel=1,
28+
)
29+
30+
31+
def test_build_cellpose_settings_invalid_dimension_mode():
32+
"""Test that build_cellpose_settings raises ValueError for invalid dimension_mode."""
33+
with pytest.raises(ValueError, match="dimension_mode"):
34+
build_cellpose_settings(
35+
model="cyto2",
36+
diameter=30.0,
37+
cyto_channel=1,
38+
dimension_mode="4D",
39+
)
40+
41+
42+
def test_build_cellpose_settings_case_insensitive_model():
43+
"""Test that build_cellpose_settings accepts model names case-insensitively."""
44+
# Should not raise
45+
build_cellpose_settings(model="CYTO2", diameter=30.0, cyto_channel=1)
46+
build_cellpose_settings(model="Nuclei", diameter=30.0, cyto_channel=1)
47+
build_cellpose_settings(model="CYTO3", diameter=30.0, cyto_channel=1)
48+
49+
50+
def test_build_cellpose_settings_valid_2d():
51+
"""Test that build_cellpose_settings succeeds for 2D mode."""
52+
settings = build_cellpose_settings(
53+
model="cyto2",
54+
diameter=30.0,
55+
cyto_channel=1,
56+
nuclei_channel=2,
57+
dimension_mode=DIMENSION_MODE_2D,
58+
)
59+
assert settings is not None
60+
61+
62+
def test_build_cellpose_settings_valid_3d():
63+
"""Test that build_cellpose_settings succeeds for 3D mode."""
64+
settings = build_cellpose_settings(
65+
model="nuclei",
66+
diameter=0.0,
67+
cyto_channel=1,
68+
dimension_mode=DIMENSION_MODE_3D,
69+
)
70+
assert settings is not None
71+
72+
73+
def test_dimension_mode_constants():
74+
"""Test that dimension mode constants have expected values."""
75+
assert DIMENSION_MODE_2D == "2D"
76+
assert DIMENSION_MODE_3D == "3D"

0 commit comments

Comments
 (0)