Originally written for MACSima data, but works with any multiplex imaging platform.
Most cell segmentation tools expect two channels: one for nuclei (usually DAPI) and one for cell boundaries (membrane or cytoplasm). But multiplex imaging gives you 30, 40, sometimes 50+ markers. Which ones do you pick for segmentation? And what if no single marker cleanly outlines your cells?
This tool lets you combine multiple channels into a single “pseudochannel” that works better for segmentation than any individual marker alone.
Say you’re segmenting immune cells in tumor tissue. You’ve got CD45, CD3, CD8, CD11b, CD68, and a dozen other markers. Some stain membranes, some stain cytoplasm, some are sparse. If you just pick one for your segmentation algorithm, you’ll miss cells that don’t express that marker.
The solution: blend multiple channels together with different weights until you get a composite that captures all cell boundaries. That’s what this does.
conda env create -f environment.yaml
conda activate Pseudochannel_gen
jupyter lab
Open notebooks/pseudochannel_explorer.ipynb and point it at your data.
The tool handles two common formats:
Folder of TIFFs - one file per channel
CHANNEL_FOLDER = "/path/to/your/roi/"
By default, marker names are extracted using the MACSima naming convention (_A-<marker> at the end):
C-001_S-000_S_APC_R-01_W-A-1_ROI-08_A-CD45_C-2B11.tif → "CD45"
MACSima mode - For MACSima data, use macsima_mode=True to enable automatic DAPI detection. This finds all DAPI images and keeps only the one with the lowest cycle number (C-number) as the nuclear marker:
explorer = create_interactive_explorer(
"/path/to/roi/",
macsima_mode=True # Auto-detects DAPI, uses MACSima naming pattern
)
For other instruments, pass a custom regex with one capture group:
channels = load_channel_folder("/path/to/data/", marker_pattern=r"^([^_]+)_")
OME-TIFF - single file with a separate marker list
OME_TIFF_PATH = "/path/to/image.ome.tiff"
MARKER_FILE = "/path/to/markers.txt"
MCMICRO format - If your marker file is in MCMICRO format (with marker_name and remove columns), use the mcmicro_markers=True flag. This automatically filters out channels marked remove=TRUE:
from pseudochannel import load_ome_tiff, OMETiffChannels, create_interactive_explorer
# With load_ome_tiff
channels = load_ome_tiff(
"/path/to/image.ome.tiff",
"/path/to/markers.csv",
mcmicro_markers=True, # Filters out remove=TRUE rows
)
Large or compressed OME-TIFF files - Use OMETiffChannels instead of load_ome_tiff(). It opens instantly (only reads metadata) and loads individual channels on-demand, which is much faster and uses less memory:
# This loads ALL channels into memory at once (slow for large files)
channels = load_ome_tiff(path, markers)
# This opens instantly and loads channels only when accessed (fast)
with OMETiffChannels(path, markers) as channels:
cd45 = channels["CD45"] # Only loads this channel
The notebook launches a widget with sliders for each channel. Drag them around and watch the preview update. The preview is downsampled so it’s fast even with large images.
You can also overlay DAPI in blue to see how your membrane pseudochannel aligns with nuclei - useful for checking that cell boundaries make sense.
Draw a rectangle on the preview to zoom in at full resolution and check the details.
Once you have a zoom region selected, you can preview Cellpose segmentation directly in the widget:
Cellpose uses your current pseudochannel weights and the nuclear marker (if available) for two-channel segmentation.
Export config for batch processing: Once you’ve tuned the segmentation parameters, export them for use with segment_mcmicro_batch():
cellpose_config = explorer.get_cellpose_config()
Note: Cellpose is an optional dependency. Install it with:
pip install cellpose
# For GPU support (much faster):
pip install cellpose torch --extra-index-url https://download.pytorch.org/whl/cu118
GPU is auto-detected when available.
Once you’re happy with the weights, save them to a YAML file:
save_config(
weights=explorer.get_weights(),
output_path="configs/membrane_weights.yaml",
name="membrane",
description="CD45 + CD3 + pan-CK blend for immune/epithelial boundaries"
)
Apply the same weights to all your ROIs:
batch_process_directory(
root_path="/data/experiment/",
config_path="configs/membrane_weights.yaml",
output_folder="/data/experiment/pseudochannels/"
)
For MCMICRO-style folder structures, use process_mcmicro_batch(). It recursively finds all experiments with a background/ folder containing an OME-TIFF and a sibling markers.csv:
from pseudochannel import find_mcmicro_experiments, process_mcmicro_batch
# Preview what will be processed
experiments = find_mcmicro_experiments("/data/CRC/")
print(f"Found {len(experiments)} experiments")
# Process all experiments
output_paths = process_mcmicro_batch(
root_path="/data/CRC/",
config_path="configs/membrane_weights.yaml",
mcmicro_markers=True, # Uses marker_name column, filters remove=TRUE
)
Output structure:
experiment/
├── markers.csv
├── background/
│ └── image.ome.tiff
└── pseudochannel/ <- Created
└── pseudochannel.tif
After generating pseudochannels, run Cellpose segmentation on all experiments:
from pseudochannel import segment_mcmicro_batch
seg_outputs = segment_mcmicro_batch(
root_path="/data/CRC/",
config=explorer.get_cellpose_config(), # Use tuned parameters from widget
mcmicro_markers=True,
)
print(f"Segmented {len(seg_outputs)} experiments")
Or do both pseudochannel generation and segmentation in one call:
from pseudochannel import process_and_segment_mcmicro_batch
pseudo_paths, seg_paths = process_and_segment_mcmicro_batch(
root_path="/data/CRC/",
config_path="configs/membrane_weights.yaml",
mcmicro_markers=True,
)
Config options for segmentation:
| Source | Usage |
|---|---|
| Widget explorer | config=explorer.get_cellpose_config() |
| YAML file | config="path/to/config.yaml" (extracts cellpose section) |
| Direct | config=CellposeConfig(diameter=30, flow_threshold=0.4) |
| Defaults | config=None (auto GPU, cyto3 model) |
| Parameter | Type | Default | Description |
|---|---|---|---|
model_type |
str | "cyto3" |
Cellpose model to use. Options: "cyto3" (latest cytoplasm), "cyto2", "cyto", "nuclei" |
diameter |
float | None | None |
Expected cell diameter in pixels. None = auto-estimate from image |
flow_threshold |
float | 0.4 |
Flow error threshold. Lower = stricter matching, fewer fragmented cells (range: 0-1) |
cellprob_threshold |
float | 0.0 |
Cell probability threshold. Higher = fewer cells, only high-confidence detections (range: -6 to 6) |
gpu |
bool | None | None |
Use GPU acceleration. None = auto-detect CUDA availability |
min_size |
int | 15 |
Minimum cell size in pixels. Cells smaller than this are removed |
Example: Create config programmatically
from pseudochannel import CellposeConfig
config = CellposeConfig(
model_type="cyto3",
diameter=35, # Set if you know your cell size
flow_threshold=0.4, # Lower for cleaner boundaries
cellprob_threshold=0.0, # Raise to reduce false positives
min_size=15,
)
Example: YAML config with cellpose section
You can add a cellpose section to your weights YAML file. When you pass this file to segment_mcmicro_batch(), the cellpose parameters are extracted automatically:
# configs/membrane_weights.yaml
name: membrane
description: CD45 + CD3 blend for immune cell boundaries
weights:
CD45: 0.3
CD3: 0.2
CD8: 0.15
pan-CK: 0.25
cellpose:
model_type: cyto3
diameter: 35
flow_threshold: 0.4
cellprob_threshold: 0.0
min_size: 15
# Both pseudochannel weights AND cellpose config come from the same file
seg_outputs = segment_mcmicro_batch(
root_path="/data/CRC/",
config="configs/membrane_weights.yaml",
)
Output structure after segmentation:
experiment/
├── markers.csv
├── background/
│ └── image.ome.tiff
├── pseudochannel/
│ └── pseudochannel.tif
└── segmentation/ <- Created
├── seg_mask.tif <- uint32 label mask
└── seg_flows.pkl <- Cellpose flows (pickle format)
Skip-existing behavior: Both process_mcmicro_batch() and segment_mcmicro_batch() skip already processed experiments by default. Use overwrite=True to recompute.
If your images are too large to segment in one pass (e.g., causing out-of-memory errors), use the tiling workflow to split images into overlapping tiles, segment each tile separately, and merge the results.
Open notebooks/tiled_segmentation.ipynb and follow the steps.
| Image size | Recommended tile size | Overlap |
|---|---|---|
| < 10k px | No tiling needed | - |
| 10k-20k px | 2048 px | 200 px |
| 20k-50k px | 4096 px | 300 px |
| > 50k px | 4096-8192 px | 400 px |
The overlap should be at least 2x the expected cell diameter to ensure cells at tile boundaries are properly detected and deduplicated.
from tiling import (
compute_tile_grid, # Plan tile layout
split_image, # Split image into tiles
save_tile_info, # Save tile metadata
load_tile_info, # Load tile metadata
load_tile_masks, # Load segmented tile masks
merge_tile_masks, # Merge with deduplication
)
# Split image
tiles, tile_infos = split_image(image, tile_size=2048, overlap=200, output_dir="tiles/")
save_tile_info(tile_infos, "tiles/tile_info.json", image_shape=image.shape)
# ... segment tiles externally ...
# Merge masks
tile_infos, metadata = load_tile_info("tiles/tile_info.json")
tile_masks = load_tile_masks("tiles/", tile_infos)
merged = merge_tile_masks(tile_masks, tile_infos, metadata['image_shape'])
DAPI, autofluorescence channels, empty channels, and a few other common non-markers are excluded from the weight sliders by default. You probably don’t want these in your membrane composite anyway. Override with exclude_channels=[] if you need them.
├── src/
│ ├── pseudochannel/ # Main package
│ │ ├── core.py # Pseudochannel computation
│ │ ├── io.py # TIFF/OME-TIFF loading
│ │ ├── widgets.py # Interactive Jupyter widget
│ │ ├── segmentation.py # Cellpose wrapper (optional)
│ │ ├── batch.py # Batch processing & segmentation
│ │ ├── config.py # Config save/load
│ │ └── preview.py # Image downsampling for previews
│ └── tiling/ # Large image tiling utilities
│ ├── split.py # Split images into tiles
│ └── merge.py # Merge segmented tile masks
├── notebooks/
│ ├── pseudochannel_explorer.ipynb # Interactive weight tuning
│ └── tiled_segmentation.ipynb # Large image tiling workflow
├── configs/ # Your saved weight configs
└── outputs/ # Generated pseudochannels
from pseudochannel import (
# I/O
load_channel_folder, # Load folder of individual TIFFs
load_ome_tiff, # Load OME-TIFF with marker file
OMETiffChannels, # Lazy-loading OME-TIFF wrapper
# Processing
compute_pseudochannel, # Compute weighted pseudochannel
# Config
save_config, # Save weights to YAML
load_config, # Load weights from YAML
# Batch processing
process_mcmicro_batch, # Generate pseudochannels for MCMICRO experiments
segment_mcmicro_batch, # Segment MCMICRO experiments with Cellpose
process_and_segment_mcmicro_batch, # Both in one call
# Segmentation
CellposeConfig, # Cellpose parameters dataclass
SegmentationResult, # Full segmentation output (masks, flows, etc.)
run_segmentation, # Run Cellpose, return masks only
run_segmentation_full, # Run Cellpose, return full results
)
from tiling import (
# Splitting
TileInfo, # Tile metadata dataclass
compute_tile_grid, # Calculate tile coordinates
split_image, # Split and save tiles
save_tile_info, # Save tile metadata to JSON
load_tile_info, # Load tile metadata from JSON
# Merging
load_tile_masks, # Load segmented tile masks
merge_tile_masks, # Merge with cell deduplication
relabel_mask, # Ensure consecutive labels 1..N
)
Optional (for segmentation preview):
See environment.yaml for the full list.