Cells from a membrane stain
Epithelia, organoids and tissues are often imaged with a membrane (or
cortex) marker plus a nuclear dye. Cellpose struggles there in a
recognisable way: Cellpose 4 (cpsam) reads every channel it is given
without a "cytoplasm" or "nucleus" role, so with a bright nuclear channel
next to a faint membrane it segments the nuclei; and on the membrane
alone, a wall that is faint in places merges two cells. Patchworks ships
three plugins for this case, plus an optional denoising step in front of any
method.
| Needs | Cells come from | Best when | |
|---|---|---|---|
| Nuclei-seeded watershed | scikit-image | the membrane, flooded from the nuclei | every cell has one nucleus; start here |
| PlantSeg | plant-seg (conda-forge) |
a boundary U-Net, then GASP / multicut | the membrane is uneven or noisy |
| PlantSeg + nuclei | same | the U-Net's boundaries and the nuclei | both of the above |
| Cellpose, membrane only | cellpose | Cellpose | nuclei_channel: null, do_3D: true |
| Denoising first | careamics |
any of these, on denoised tiles | noisy acquisitions |
All of them are ordinary custom functions: one config block, the same tiling, merge, tables and review as any other run.
Nuclei-seeded watershed
The nuclei are the easy part of the image: bright, compact, separated. They say how many cells there are and where; flooding the membrane image from them grows each nucleus out to its cell's walls. A cell can then not be split (one seed each) or merged with its neighbour (two seeds never join), even across a gap in the wall.
channel: 0 # membrane
nuclei_channel: 1 # stacked onto each tile as [membrane, nuclei]
method: "custom"
label_name: "cyto_labels"
stitch: "iou" # required: see below
custom:
module: "patchworks.plugins.watershed"
kwargs:
nuclei_min_size: 200 # voxels: specks smaller than a nucleus
foreground: "otsu" # stop at the tissue edge
# max_radius_um: 15 # or: no further than this from the nucleus
# nuclei_threshold: 800 # intensity, if Otsu misses dim nuclei
stitch: "iou" is required with this plugin and with PlantSeg, and
prepare refuses the config without it. They give every voxel to some
cell, so neighbouring cells touch at every tile seam, and the default
"touch" stitching would join each such pair: on a test grid of 12 cells
cut by six tiles, 5 came out. "iou" joins two pieces only where both tiles
agree on their overlap, and gives the 12. (From the command line, pass
--stitch iou yourself: nothing checks it there.)
Two nuclei touching each other give one seed, so one cell for two: raise
nuclei_threshold if that happens -- or grow the cells from nuclei you have
already segmented, with seed_labels instead of nuclei_channel (see
the example below). Each tile's halo (overlap) must hold a
whole cell, as for any method.
PlantSeg
PlantSeg predicts cell boundaries with a 3-D U-Net trained on membrane stains, then partitions the boundary map into cells (supervoxels by a distance-transform watershed, merged by GASP, mutex watershed or multicut). A wall the U-Net sees at all gets closed by the partitioning, where Cellpose would merge across it.
PlantSeg is on conda-forge only, so it has its own pixi environment:
pixi install -e plantseg
pixi run -e plantseg plantseg-fetch generic_confocal_3D_unet # once, with internet
pixi run -e plantseg multi-slurm
method: "custom"
stitch: "iou" # required, as for the watershed
custom:
module: "patchworks.plugins.plantseg"
kwargs:
model: "generic_confocal_3D_unet" # or generic_light_sheet_3D_unet, ...
segmentation: "gasp" # gasp | mutex_ws | multicut | dt_watershed
beta: 0.6 # lower merges more, higher splits more
foreground: "otsu" # a boundary U-Net puts cells everywhere
With nuclei_channel set, two more segmentation modes use the nuclei:
"nuclei_watershed"-- the U-Net's boundary map flooded from the nuclei: the watershed above, on a much cleaner boundary image."lifted_multicut"-- PlantSeg's lifted multicut: supervoxels in one nucleus pulled together, in different nuclei pushed apart.
Each tile is resampled to the voxel size the model was trained at (from the
image's own calibration, rescale: true), which matters more than any other
setting for a pretrained U-Net; the prediction is resampled back.
Example: Cellpose nuclei + PlantSeg cells in one run
The workflow ships this pairing ready to edit: Cellpose segments the nuclei,
then PlantSeg grows the cells from exactly those nuclei -- one cell per
nucleus Cellpose found -- and the two are related. Three files, next to
config/multi.yaml:
# config/multi_plantseg.yaml
common: config/common.yaml # input, work_dir, tile_shape, level: shared
segmentations:
- config/config_nuclei.yaml # Cellpose "nuclei" model on channel 1
- config/config_cyto_plantseg.yaml # PlantSeg on channel 0, seeded by nuclei_labels
relations:
- a: nuclei_labels
b: cyto_labels
output: nuclei_to_cyto.xlsx
review: # a sanity check: one nucleus per cell
expect:
cyto_labels:
nuclei_labels: 1
# config/config_nuclei.yaml (unchanged)
channel: 1
overlap: [4, 30, 30]
method: "cellpose"
label_name: "nuclei_labels"
cellpose:
model: "nuclei"
diameter: 15
do_3D: true
gpu: true
# config/config_cyto_plantseg.yaml
channel: 0 # membrane
seed_labels: "nuclei_labels" # each tile becomes [membrane, nuclei labels]
overlap: [4, 40, 40] # the halo must hold a whole cell
stitch: "iou" # cells touch at every seam: join on agreement only
method: "custom"
label_name: "cyto_labels"
custom:
module: "patchworks.plugins.plantseg"
function: "segment"
kwargs:
model: "generic_confocal_3D_unet" # generic_light_sheet_3D_unet for light-sheet
segmentation: "nuclei_watershed" # U-Net boundaries flooded from the nuclei
foreground: "otsu" # keep the tissue only
# max_radius_um: 15
Set input and work_dir in config/common.yaml, then, from workflow/:
pixi install -e plantseg
pixi run -e plantseg plantseg-fetch generic_confocal_3D_unet # once, with internet
pixi run -e plantseg multi-plantseg-dry # check the plan
pixi run -e plantseg multi-plantseg-slurm # submit
The plantseg environment is the default one plus PlantSeg, so the
Cellpose run comes from it too. Afterwards, nuclei_to_cyto.xlsx gives
each nucleus its cell, and pixi run -e viewer review <work_dir>/image.zarr
lists any cell not holding exactly one nucleus.
How seed_labels works
- Order.
run_multistarts the cells' config only once the config producingnuclei_labelshas finished; everything else listed (cilia, say) runs alongside. If the nuclei fail, the cells are skipped, not run without seeds. Two configs seeding each other are refused up front. A dry run (-n) waits for nothing. - Tiles. The nuclei label image is stacked onto the membrane as each
tile's second channel, halo included, so neighbouring tiles see the same
nuclei and a cell crossing a seam is grown from the same nucleus on both
sides. Both runs must use the same
level, whichrun_multialready enforces; the plugin is toldseeds: "labels"automatically. - Seeds as given. Two touching nuclei that Cellpose split stay two
cells; a dim nucleus Cellpose found still gets its cell. Cellpose's
mistakes carry over the same way: a nucleus split in two makes two
cells. Correcting the nuclei first (
patchworks review, then--write-labels nuclei_labelsandseed_labels: nuclei_labels_reviewed) gives the cells the corrected nuclei. - On its own, outside
run_multi, the cells' config needslabels/nuclei_labelsalready inimage.zarr:preparechecks, and stops with a message rather than segmenting without seeds. - Re-segmenting the nuclei does not re-run the cells by itself: delete
<work_dir>/cyto_labelsandimage.zarr/labels/cyto_labelsto grow them again from the new nuclei.
nuclei_channel: 1 instead of seed_labels makes the plugin find the
nuclei itself, in the nuclear stain (Otsu per tile, nuclei_* options),
independently of Cellpose -- both segmentations then run at the same time.
Without PlantSeg, the same works with the plain
nuclei-seeded watershed: set module:
"patchworks.plugins.watershed" with only the foreground and
max_radius_um keys, and use the default environment (pixi run
multi-slurm with this pair listed in config/multi.yaml).
Cellpose: membrane only, in 3-D
If you stay with Cellpose on such images:
- Give it only the membrane (
nuclei_channel: null):cpsamthen has nothing brighter to lock onto. do_3D: truecombines the three orthogonal views, so a wall faint in one plane is found in the others.stitch_thresholdinstead joins independent 2-D masks across z and fixes nothing in the masks themselves. A GPU (gpu: true) only changes the speed.
Denoising first (CAREamics)
Noise breaks segmentations: a wall lost in the noise merges two cells. Noise2Void learns to remove the noise from the image itself (no clean ground truth, no annotation), so a model trained once on a few crops of the store denoises every tile before any method sees it.
pixi install -e careamics
# on a GPU node: the brightest crops of the channel, ~30 epochs
pixi run -e careamics denoise-train <work_dir>/image.zarr --channel 0 --out n2v_membrane.ckpt
pixi run -e careamics denoise-train <work_dir>/image.zarr --channel 1 --out n2v_nuclei.ckpt
then, in the segmentation config, with any method:
denoise:
model: "/path/to/n2v_membrane.ckpt" # .ckpt, or a BioImage.IO .zip
nuclei_model: "/path/to/n2v_nuclei.ckpt" # optional, for nuclei_channel
# tile_size: [16, 256, 256] # CAREamics' tiling inside a tile (VRAM)
and run from the careamics environment (plantseg-careamics for both).
The model paths are checked during prepare; the denoised image is not
stored, only used for segmenting. From the command line, patchworks segment
... --denoise n2v_membrane.ckpt does the same.
Look at a denoised tile before a full run: