Three-channel analysis with z-projection tutorial

This tutorial walks through a three-channel CellColoc workflow based on the interactive Jupyter notebook

user_scripts/nb_microglia_3D_three_channel_zproject_user_script.ipynb,

which is identical to the interactive Python script

user_scripts/microglia_3D_three_channel_zproject_user_script.py.

The goal of this tutorial is to show when and how a nominally 3D microscopy stack can be projected along the z axis before segmentation and downstream colocalization analysis. This can be a very useful strategy because true 3D Cellpose segmentation is computationally expensive.

When, and only when,

  • the *same* biological cells are visible in the relevant channels,

  • these channels mainly differ in staining or marker identity rather than in which objects are present,

  • and the cells are not densely stacked on top of one another along z,

then a z-projection can be a good approximation.

In that situation, CellColoc’s z-projection workflow often offers two practical advantages:

  • the analysis becomes substantially faster,

  • and the projected 2D image may even segment more robustly because signal from many z slices is compressed into one plane.

But this comes with a tradeoff: By projecting, you deliberately give up one spatial axis and therefore lose true 3D object geometry. This tutorial therefore shows z-projection as an optional strategy and not as a universal replacement for full 3D analysis.

Dataset used in this tutorial

The tutorial uses the microglia example data set distributed with CellColoc in

example_data/microglia_3D/

Please download the example data from the CellColoc Zenodo example-data record first, as described in the Example data set section. Store the downloaded files locally in a convenient place. For the remainder of this tutorial, we assume that the downloaded files are available relative to the current working directory or current example script in:

example_data/microglia_3D/

The script is written to handle one selected file from that folder at a time.

This is a real 3D multichannel fluorescence dataset. In this tutorial, we treat:

  • channel 0 as the primary cell channel (Cx3cr1-tdTomato microglia reporter signal),

  • channel 1 as the first marker channel (Iba1 staining),

  • channel 2 as the optional third analysis channel (DAPI in this demo setup).

3D multi-channel image stack of hippocampal CA1 tissue, showing microglia, Iba1, and DAPI channels in napari.
3D multi-channel image stack of hippocampal CA1 tissue, showing microglia, Iba1, and DAPI channels in napari.

The 3D multi-channel image stack with the raw microglia (magenta), Iba1 (cyan), and DAPI (yellow) channels shown in Napari. Top shows 2D representation, bottom shows 3D representation. The DAPI channel is used for anatomical orientation but not segmented in this tutorial. The microglia channel is segmented with Cellpose, while the Iba1 channel is segmented with Otsu thresholding.

The biological idea of this tutorial is to demonstrate the mechanics of:

  • projecting a 3D stack into a 2D analysis view,

  • segmenting multiple channels on that projected view,

  • evaluating channel-0 positivity against both channel 1 and channel 2,

  • and inspecting the resulting positivity combinations separately.

How to use this tutorial

The associated user script

user_scripts/nb_microglia_3D_three_channel_zproject_user_script.ipynb

is organized in cells, reflecting the structure of this tutorial. The same applies to the alternative Python script (there: # %% cells)

user_scripts/microglia_3D_three_channel_zproject_user_script.py.

The recommended way to follow this tutorial is:

  1. open user_scripts/nb_microglia_3D_three_channel_zproject_user_script.ipynb or user_scripts/microglia_3D_three_channel_zproject_user_script.py,

  2. run the cells from top to bottom,

  3. adapt the configuration values for your own dataset only where needed.

The subsections below follow the same order as the script cells.

Imports

The first cell imports the public CellColoc API, napari, NumPy, and dataclasses.replace:

from pathlib import Path
from dataclasses import replace

import napari
import numpy as np

from cellcoloc import (
    CellposeModelConfig,
    ChannelConfig,
    ColocalizationConfig,
    DisplayNames,
    RuntimeConfig,
    analyze_existing_masks,
    build_positive_cell_mask,
    create_full_image_roi_labels,
    create_roi_drawing_viewer,
    export_analysis_outputs,
    extract_label_masks_from_viewer,
    load_analysis_images,
    load_roi_labels,
    prepare_loaded_images_for_analysis,
    refine_run_result_from_cellpose_cache,
    run_roi_cellpose_colocalization,
    save_roi_labels_from_shapes,
    show_analysis_results,
    try_load_roi_labels)

PROJECT_ROOT = Path(__file__).resolve().parents[1]

What this cell does:

  • locates the repository root,

  • imports the reusable CellColoc workflow functions,

  • imports napari for ROI drawing and interactive inspection,

  • imports build_positive_cell_mask for the dedicated positivity views at the end,

  • imports replace so that temporary refinement configs can be derived from the original base configs without overwriting them.

Project settings

The project settings cell contains the full configuration for this projected three-channel workflow:

DATA_DIR = PROJECT_ROOT / "example_data" / "microglia_3D"
DATA_PATHS = sorted(DATA_DIR.glob("*"))
allowed_extensions = [".czi", ".tif", ".tiff", ".ome.tif", ".ome.tiff"]
DATA_PATHS = [p for p in DATA_PATHS if p.suffix.lower() in allowed_extensions]
DATA_PATHS = [p for p in DATA_PATHS if not p.name.startswith(".")]

CHANNEL_CONFIG = ChannelConfig(
    cell_channel=0,
    marker_channel=1,
    optional_region_channel=2)

DISPLAY_NAMES = DisplayNames(
    cell="Cx3cr1-tdTomato",
    marker="Iba1",
    optional_region="DAPI",
    positive_cells="tdTomato + Iba1 positive masks")

VOXEL_SCALE_ZYX = None

CELL_MODEL_CONFIG = CellposeModelConfig(
    model_name_or_path="cpsam",
    segmentation_method="cellpose",
    diameter=None,
    z_crop=None,        # None; optional global analysis z crop as (start, stop); applies to all channels and all ROIs
    z_projection="max",  # None, "max", "mean", "median", "std", or "var"
    anisotropy=True,
    flow3d_smooth=0,
    prefilter=None,
    prefilter_sigma_xy=0.0,
    prefilter_sigma_z=0.0,
    prefilter_median_size_xy=3,
    prefilter_median_size_z=3,
    postfilters=None,
    min_intensity_measure="mean",
    min_intensity_threshold=None,
    local_contrast_k=1.0,
    local_contrast_shell_inner_radius=1,
    local_contrast_shell_outer_radius=4,
    bright_pixel_measure="count",
    bright_pixel_threshold=None,
    bright_pixel_min_count=None,
    bright_pixel_min_fraction=None,
    cellprob_threshold=1.5,
    flow_threshold=0.4)

MARKER_MODEL_CONFIG = CellposeModelConfig(
    model_name_or_path="cpsam",
    segmentation_method="otsu",
    diameter=None,
    # z_crop=None,
    # z_projection="max", # it's not necessary to repeat z-projection option in marker channel; when set in the cell channel, it will be applied to all channels
    anisotropy=True,
    flow3d_smooth=0,
    prefilter=None,
    prefilter_sigma_xy=0.0,
    prefilter_sigma_z=0.0,
    prefilter_median_size_xy=3,
    prefilter_median_size_z=3,
    postfilters=None,
    min_intensity_measure="mean",
    min_intensity_threshold=None,
    local_contrast_k=1.0,
    local_contrast_shell_inner_radius=1,
    local_contrast_shell_outer_radius=4,
    bright_pixel_measure="count",
    bright_pixel_threshold=None,
    bright_pixel_min_count=None,
    bright_pixel_min_fraction=None,
    cellprob_threshold=0.0,
    flow_threshold=0.4)

OPTIONAL_REGION_MODEL_CONFIG = CellposeModelConfig(
    model_name_or_path="cpsam",
    segmentation_method="cellpose",
    diameter=None,
    #z_crop=None,
    #z_projection="max", # it's not necessary to repeat z-projection option in optional region channel; when set in the cell channel, it will be applied to all channels
    anisotropy=True,
    flow3d_smooth=0,
    prefilter=None,
    prefilter_sigma_xy=0.0,
    prefilter_sigma_z=0.0,
    prefilter_median_size_xy=3,
    prefilter_median_size_z=3,
    postfilters=None,
    min_intensity_measure="mean",
    min_intensity_threshold=None,
    local_contrast_k=1.0,
    local_contrast_shell_inner_radius=1,
    local_contrast_shell_outer_radius=4,
    bright_pixel_measure="count",
    bright_pixel_threshold=None,
    bright_pixel_min_count=None,
    bright_pixel_min_fraction=None,
    cellprob_threshold=0.0,
    flow_threshold=0.4)

"""
To exclude the optional third channel from segmentation and from the downstream
cell-positivity analysis, update the script as follows:

1. In ``CHANNEL_CONFIG``, set:

   ``optional_region_channel=None``

2. Set this variable to:

   ``OPTIONAL_REGION_MODEL_CONFIG = None``

3. In ``COLOCALIZATION_CONFIG``, set:

   ``evaluate_optional_region_cell_positivity=False``

4. Skip or remove the later visualization cells that explicitly show:

   - channel 0 + channel 2 positive cells
   - channel 0 + channel 1 + channel 2 positive cells

This reduces the workflow back to an ordinary two-channel analysis while
keeping the rest of the script structure intact.
"""

COLOCALIZATION_CONFIG = ColocalizationConfig(
    min_cell_voxels=50,
    overlap_fraction_threshold=0.02,
    min_overlap_voxels=10,
    evaluate_optional_region_cell_positivity=True)

RUNTIME_CONFIG = RuntimeConfig(
    draw_rois=True,
    process_rois=True,
    open_results=True,
    use_gpu=True,
    crop_for_testing=None,
    image_loading_mode="memap")

USE_FULL_IMAGE_AS_SINGLE_ROI = False
REUSE_EXISTING_ROI_MASK_IF_AVAILABLE = True
INITIAL_RESULT_LAYER_KEYS = [
    "cell_image",
    "marker_image",
    "optional_region_image",
    "rois",
    "roi_numbers",
    "cell_masks",
    "marker_masks",
    "positive_cells",
    "optional_region_labels"]
REFINEMENT_RESULT_LAYER_KEYS = [
    "cell_masks",
    "marker_masks",
    "positive_cells",
    "optional_region_labels",
    "rois"]

print("Detected input files:")
for data_path in DATA_PATHS:
    print(f" - {data_path.name}")

SELECTED_FILE_NAME = DATA_PATHS[0].name
DATA_PATH = DATA_DIR / SELECTED_FILE_NAME
print(f"Selected file for analysis:\n{DATA_PATH}")

This is the most important cell to adapt for your own projected 3D analysis.

Input-file discovery and selection

The script scans example_data/microglia_3D/ for supported microscopy files and lets you select one through:

SELECTED_FILE_NAME = DATA_PATHS[0].name

To analyze a different stack from the same folder, simply change that assignment.

Channel assignment and display names

CHANNEL_CONFIG assigns the three analysis channels:

  • cell_channel=0

  • marker_channel=1

  • optional_region_channel=2

DISPLAY_NAMES controls how those channels and result layers appear in napari.

In this tutorial, all three channels are part of the analysis. The third channel is not only displayed for orientation, but also segmented and included in downstream per-cell positivity analysis.

Voxel scale

VOXEL_SCALE_ZYX is set to None here. This tells CellColoc to first try to resolve the voxel size from OMIO metadata. If needed, you can also provide it explicitly as:

  • a full (Z, Y, X) tuple for 3D datasets,

  • or, for 2D-oriented workflows, as a shorter (Y, X) tuple.

Why z-projection is configured in the cell config

The key option for this tutorial is:

z_projection="max"

in CELL_MODEL_CONFIG.

This tells CellColoc to project the stack along z before segmentation and analysis. Supported projection methods are:

  • None: do not project,

  • "max"

  • "mean"

  • "median"

  • "std"

  • "var"

If you additionally define z_crop=(z_start, z_stop), only that z interval is projected.

Once a projection is active:

  • CellColoc automatically applies the same projection to all channels,

  • the analysis runs on the resulting 2D view rather than on the full 3D stack,

  • later visualizations also show the projected analysis image,

  • and Cellpose is automatically run in the effective 2D mode for the projected channel.

Segmentation configurations

This tutorial defines three channel configs:

  • CELL_MODEL_CONFIG

  • MARKER_MODEL_CONFIG

  • OPTIONAL_REGION_MODEL_CONFIG

The important idea is that CellColoc lets you mix segmentation backends even in a projected workflow.

In the current script:

  • channel 0 uses Cellpose with segmentation_method="cellpose",

  • channel 1 uses a classical threshold-based workflow with segmentation_method="otsu",

  • channel 2 uses Cellpose again.

This mixed setup is deliberate. It shows that z-projection is not tied to one single segmentation backend.

Optional third channel

OPTIONAL_REGION_MODEL_CONFIG activates segmentation of the third channel. Directly below that config, the script contains an explanatory block showing how to disable the third channel again if you want to fall back to a two-channel workflow.

In short, you would then:

  • set optional_region_channel=None in CHANNEL_CONFIG,

  • set OPTIONAL_REGION_MODEL_CONFIG = None,

  • set evaluate_optional_region_cell_positivity=False in COLOCALIZATION_CONFIG,

  • and skip the later dedicated third-channel positivity views.

Third-channel positivity evaluation

The key switch that activates per-cell positivity analysis against channel 2 is:

evaluate_optional_region_cell_positivity=True

inside COLOCALIZATION_CONFIG.

This means the script reports not only:

  • which channel-0 cells are positive against channel 1,

but also:

  • which channel-0 cells are positive against channel 2,

  • and which channel-0 cells are positive for both channel 1 and channel 2.

Runtime settings and ROI mode

The runtime settings control the interactive analysis mode:

  • image_loading_mode="memap" uses OMIO’s disk-backed loading mode,

  • USE_FULL_IMAGE_AS_SINGLE_ROI = False enables ROI-based analysis,

  • REUSE_EXISTING_ROI_MASK_IF_AVAILABLE = True reuses a saved ROI mask when available.

If you want to analyze the full field of view as one ROI, set:

USE_FULL_IMAGE_AS_SINGLE_ROI = True

Load the analysis channels

The next cell loads the selected stack and then prepares the projected analysis view:

loaded_images = load_analysis_images(
    source_path=DATA_PATH,
    channel_config=CHANNEL_CONFIG,
    voxel_scale_zyx=VOXEL_SCALE_ZYX,
    crop_for_testing=RUNTIME_CONFIG.crop_for_testing,
    image_loading_mode=RUNTIME_CONFIG.image_loading_mode)
loaded_images = prepare_loaded_images_for_analysis(
    loaded_images,
    CELL_MODEL_CONFIG,
    MARKER_MODEL_CONFIG,
    OPTIONAL_REGION_MODEL_CONFIG)
print(f"Results directory:\n{loaded_images.paths.results_dir}")
print("Prepared analysis view: "
      f"shape={loaded_images.cell_image.shape}, "
      f"is_3d={loaded_images.is_3d}, "
      f"z_projection={loaded_images.z_projection_method!r}, "
      f"analysis_z_bounds={loaded_images.analysis_z_bounds}")

existing_roi_labels = None
if REUSE_EXISTING_ROI_MASK_IF_AVAILABLE:
    existing_roi_labels = try_load_roi_labels(loaded_images.paths.roi_mask_path)

This is the key difference from the standard full-3D workflow.

The sequence is:

  1. load the original microscopy stack through OMIO,

  2. extract the configured channels,

  3. call prepare_loaded_images_for_analysis(...),

  4. obtain the projected 2D analysis image that will be used by all later steps.

The printed status output tells you:

  • the prepared image shape,

  • whether the resulting analysis view is effectively 3D or not,

  • which z-projection method was applied,

  • and which z interval was used.

Draw ROIs interactively in napari

The ROI-drawing cell behaves just like in the other tutorials:

if USE_FULL_IMAGE_AS_SINGLE_ROI:
    print("Whole-image mode is enabled. ROI drawing is skipped.")
elif existing_roi_labels is not None:
    print("An existing ROI mask was found and will be reused. ROI drawing is skipped.")
elif RUNTIME_CONFIG.draw_rois:
    roi_viewer, shapes_layer = create_roi_drawing_viewer(
        loaded_images=loaded_images,
        display_names=DISPLAY_NAMES,
    )
    print("Draw ROIs in napari and close the window. Then run the next cell.")
    napari.run()
else:
    print("ROI drawing is disabled. The next cell will load an existing ROI mask from disk.")

Because the analysis image has already been projected, the ROI drawing now happens on the projected 2D view rather than on the original 3D stack.

This is exactly what you want in a projected workflow: the ROIs should match the image that will actually be segmented and analyzed.

Save the drawn ROIs or load an existing ROI mask

The next cell resolves the ROI source:

if USE_FULL_IMAGE_AS_SINGLE_ROI:
    roi_labels_2d = create_full_image_roi_labels(loaded_images.cell_image.shape[1:])
elif existing_roi_labels is not None:
    roi_labels_2d = existing_roi_labels
elif RUNTIME_CONFIG.draw_rois:
    roi_labels_2d = save_roi_labels_from_shapes(
        shapes_layer=shapes_layer,
        output_path=loaded_images.paths.roi_mask_path,
        image_shape_yx=loaded_images.cell_image.max(axis=0).shape,
        scale_yx=loaded_images.voxel_scale_zyx[1:])
else:
    roi_labels_2d = load_roi_labels(loaded_images.paths.roi_mask_path)

roi_ids = np.unique(roi_labels_2d)
roi_ids = roi_ids[roi_ids != 0]
print(f"ROI ids: {roi_ids}")
result_viewer = None

The order is:

  • full-image mode if enabled,

  • otherwise reuse an existing ROI mask when available,

  • otherwise save the newly drawn ROIs,

  • otherwise load a previously saved ROI mask explicitly.

The resulting roi_labels_2d is then used for the entire rest of the run.

Run the ROI-wise three-channel segmentation and colocalization analysis

The main analysis cell runs segmentation and table generation:

run_result = run_roi_cellpose_colocalization(
    loaded_images=loaded_images,
    roi_labels_2d=roi_labels_2d,
    cell_model_config=CELL_MODEL_CONFIG,
    marker_model_config=MARKER_MODEL_CONFIG,
    colocalization_config=COLOCALIZATION_CONFIG,
    runtime_config=RUNTIME_CONFIG,
    optional_region_model_config=OPTIONAL_REGION_MODEL_CONFIG,
    optional_region_result=None)
print("Projected three-channel analysis finished. "
      f"Overview rows: {len(run_result.tables.overview)}, "
      f"summary rows: {len(run_result.tables.summary)}.")

This step:

  • segments channel 0 on the projected image with Cellpose,

  • segments channel 1 on the projected image with Otsu thresholding,

  • segments channel 2 on the projected image with Cellpose,

  • computes occupancy for all segmented channels,

  • evaluates channel-0 positivity against channel 1,

  • optionally evaluates channel-0 positivity against channel 2,

  • derives the combined channel-0 plus channel-1 plus channel-2 positivity.

This is the main computational payoff of the projected workflow: instead of running the expensive parts of the segmentation stack in true 3D, the analysis now runs on a compressed 2D representation of the stack.

Visualize the base result in napari

The next cell opens the projected analysis result in napari:

if RUNTIME_CONFIG.open_results:
    result_viewer = show_analysis_results(
        loaded_images=loaded_images,
        roi_labels_2d=roi_labels_2d,
        run_result=run_result,
        display_names=DISPLAY_NAMES,
        optional_region_result=None,
        viewer=result_viewer,
        layers_to_show=INITIAL_RESULT_LAYER_KEYS,
        replace_existing_layers=True,
        show_optional_region_image=True)

    print(f"Inspecting visualization for:\n{SELECTED_FILE_NAME}")
    napari.run()
2D-projection of the original 3D stack, showing the microglia (magenta), Iba1 (cyan), and optional third channel (yellow), along with the segmentation layer of the microglia cells that are positive for both marker channels.

2D-projection of the original 3D stack, showing the microglia (magenta), Iba1 (cyan), and optional third channel (yellow), along with the segmentation layer of the microglia cells that are positive for both marker channels.

What you see here is no longer the original full 3D stack. Instead, CellColoc shows the projected analysis view and the corresponding segmentation layers.

This is intentional. Once you choose a z-projection, all downstream segmentation and result inspection should refer to the same projected data.

Zoom onto the analyzed ROI, showing all channels and their segmented label layers.
Microglia channel only.
The segmentation layer of the microglia channel, showing the Cellpose-segmented cell objects.

Top: Zoom onto the analyzed ROI, showing all channels and their segmented label layers. Center: Microglia channel only. Bottom: Segmentation layer of the microglia channel, showing the Cellpose-segmented cell objects. Note that we miss some microglia cells in the center of the ROI. We will try to recover them in the optional refinement step below.

Zoom onto the analyzed ROI, showing the marker channel and its segmented label layer.
Segmentation layer of the marker channel, showing the Otsu-segmented marker objects.

Top: Zoom onto the analyzed ROI, showing the marker channel and its segmented label layer. Bottom: Marker channel only. The marker channel is segmented with Otsu thresholding, which results in a rather rough mask. However, since we are only interested in per-cell positivity (which microglia is Iba1-positive?), this is sufficient for the current demonstration.

Zoom onto the analyzed ROI, showing the optional third channel and its segmented label layer.
Segmentation layer of the optional third channel, showing the Cellpose-segmented third channel objects.

Top: Zoom onto the analyzed ROI, showing the optional third channel and its segmented label layer. Bottom: Segmented third channel only. The optional third channel is segmented with Cellpose, which results in an almost perfect mask this time. Cellpose’s segmentation quality can vary across channels and tends to work best for more “roundish” objects (the microglia’s processes complicate the Cellpose segmentation).

Microglia segmentation layer, including only cells that are both Iba1-positive and DAPI-positive.

Microglia segmentation layer, including only cells that are both Iba1-positive and DAPI-positive. This is the most specific view of the three-channel analysis, showing only microglia that are positive for both marker channels. It demonstrates that the optional third channel can be used to refine the per-cell positivity analysis and create more specific subsets of cells.

Optionally refine the projected result and visualize the updated result

The next cell optionally refines the result after the first inspection:

REFINE_WITH_CACHED_CELLPOSE_OUTPUTS = True
REFINEMENT_ANALYSIS_Z_CROP = None

REFINED_CELL_CELLPROB_THRESHOLD = CELL_MODEL_CONFIG.cellprob_threshold - 1.5
REFINED_CELL_FLOW_THRESHOLD     = CELL_MODEL_CONFIG.flow_threshold -0.1

REFINED_MARKER_CELLPROB_THRESHOLD = MARKER_MODEL_CONFIG.cellprob_threshold
REFINED_MARKER_FLOW_THRESHOLD = MARKER_MODEL_CONFIG.flow_threshold

REFINED_OPTIONAL_REGION_CELLPROB_THRESHOLD = OPTIONAL_REGION_MODEL_CONFIG.cellprob_threshold
REFINED_OPTIONAL_REGION_FLOW_THRESHOLD = OPTIONAL_REGION_MODEL_CONFIG.flow_threshold

REFINED_CELL_POSTFILTERS = None
REFINED_CELL_MIN_INTENSITY_MEASURE = "mean"
REFINED_CELL_MIN_INTENSITY_THRESHOLD = None
REFINED_CELL_LOCAL_CONTRAST_K = 1.0
REFINED_CELL_LOCAL_CONTRAST_SHELL_INNER_RADIUS = 1.0
REFINED_CELL_LOCAL_CONTRAST_SHELL_OUTER_RADIUS = 4
REFINED_CELL_BRIGHT_PIXEL_MEASURE = "count"
REFINED_CELL_BRIGHT_PIXEL_THRESHOLD = None
REFINED_CELL_BRIGHT_PIXEL_MIN_COUNT = None
REFINED_CELL_BRIGHT_PIXEL_MIN_FRACTION = None

REFINED_MARKER_POSTFILTERS = None
REFINED_MARKER_MIN_INTENSITY_MEASURE = "mean"
REFINED_MARKER_MIN_INTENSITY_THRESHOLD = None
REFINED_MARKER_LOCAL_CONTRAST_K = 1.0
REFINED_MARKER_LOCAL_CONTRAST_SHELL_INNER_RADIUS = 1.0
REFINED_MARKER_LOCAL_CONTRAST_SHELL_OUTER_RADIUS = 4
REFINED_MARKER_BRIGHT_PIXEL_MEASURE = "count"
REFINED_MARKER_BRIGHT_PIXEL_THRESHOLD = None
REFINED_MARKER_BRIGHT_PIXEL_MIN_COUNT = None
REFINED_MARKER_BRIGHT_PIXEL_MIN_FRACTION = None

REFINED_OPTIONAL_REGION_POSTFILTERS = None
REFINED_OPTIONAL_REGION_MIN_INTENSITY_MEASURE = "mean"
REFINED_OPTIONAL_REGION_MIN_INTENSITY_THRESHOLD = None
REFINED_OPTIONAL_REGION_LOCAL_CONTRAST_K = 1.0
REFINED_OPTIONAL_REGION_LOCAL_CONTRAST_SHELL_INNER_RADIUS = 1.0
REFINED_OPTIONAL_REGION_LOCAL_CONTRAST_SHELL_OUTER_RADIUS = 4
REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_MEASURE = "count"
REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_THRESHOLD = None
REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_MIN_COUNT = None
REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_MIN_FRACTION = None

effective_refinement_z_crop = (
    CELL_MODEL_CONFIG.z_crop if REFINEMENT_ANALYSIS_Z_CROP is None else REFINEMENT_ANALYSIS_Z_CROP)

if REFINE_WITH_CACHED_CELLPOSE_OUTPUTS:
    refined_cell_model_config = replace(
        CELL_MODEL_CONFIG,
        z_crop=effective_refinement_z_crop,
        postfilters=REFINED_CELL_POSTFILTERS,
        min_intensity_measure=REFINED_CELL_MIN_INTENSITY_MEASURE,
        min_intensity_threshold=REFINED_CELL_MIN_INTENSITY_THRESHOLD,
        local_contrast_k=REFINED_CELL_LOCAL_CONTRAST_K,
        local_contrast_shell_inner_radius=REFINED_CELL_LOCAL_CONTRAST_SHELL_INNER_RADIUS,
        local_contrast_shell_outer_radius=REFINED_CELL_LOCAL_CONTRAST_SHELL_OUTER_RADIUS,
        bright_pixel_measure=REFINED_CELL_BRIGHT_PIXEL_MEASURE,
        bright_pixel_threshold=REFINED_CELL_BRIGHT_PIXEL_THRESHOLD,
        bright_pixel_min_count=REFINED_CELL_BRIGHT_PIXEL_MIN_COUNT,
        bright_pixel_min_fraction=REFINED_CELL_BRIGHT_PIXEL_MIN_FRACTION)
    refined_marker_model_config = replace(
        MARKER_MODEL_CONFIG,
        z_crop=effective_refinement_z_crop,
        postfilters=REFINED_MARKER_POSTFILTERS,
        min_intensity_measure=REFINED_MARKER_MIN_INTENSITY_MEASURE,
        min_intensity_threshold=REFINED_MARKER_MIN_INTENSITY_THRESHOLD,
        local_contrast_k=REFINED_MARKER_LOCAL_CONTRAST_K,
        local_contrast_shell_inner_radius=REFINED_MARKER_LOCAL_CONTRAST_SHELL_INNER_RADIUS,
        local_contrast_shell_outer_radius=REFINED_MARKER_LOCAL_CONTRAST_SHELL_OUTER_RADIUS,
        bright_pixel_measure=REFINED_MARKER_BRIGHT_PIXEL_MEASURE,
        bright_pixel_threshold=REFINED_MARKER_BRIGHT_PIXEL_THRESHOLD,
        bright_pixel_min_count=REFINED_MARKER_BRIGHT_PIXEL_MIN_COUNT,
        bright_pixel_min_fraction=REFINED_MARKER_BRIGHT_PIXEL_MIN_FRACTION)
    refined_optional_region_model_config = replace(
        OPTIONAL_REGION_MODEL_CONFIG,
        z_crop=effective_refinement_z_crop,
        postfilters=REFINED_OPTIONAL_REGION_POSTFILTERS,
        min_intensity_measure=REFINED_OPTIONAL_REGION_MIN_INTENSITY_MEASURE,
        min_intensity_threshold=REFINED_OPTIONAL_REGION_MIN_INTENSITY_THRESHOLD,
        local_contrast_k=REFINED_OPTIONAL_REGION_LOCAL_CONTRAST_K,
        local_contrast_shell_inner_radius=REFINED_OPTIONAL_REGION_LOCAL_CONTRAST_SHELL_INNER_RADIUS,
        local_contrast_shell_outer_radius=REFINED_OPTIONAL_REGION_LOCAL_CONTRAST_SHELL_OUTER_RADIUS,
        bright_pixel_measure=REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_MEASURE,
        bright_pixel_threshold=REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_THRESHOLD,
        bright_pixel_min_count=REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_MIN_COUNT,
        bright_pixel_min_fraction=REFINED_OPTIONAL_REGION_BRIGHT_PIXEL_MIN_FRACTION)

    run_result = refine_run_result_from_cellpose_cache(
        loaded_images=loaded_images,
        roi_labels_2d=roi_labels_2d,
        run_result=run_result,
        colocalization_config=COLOCALIZATION_CONFIG,
        cell_model_config=refined_cell_model_config,
        marker_model_config=None,
        optional_region_model_config=refined_optional_region_model_config,
        cell_cellprob_threshold=REFINED_CELL_CELLPROB_THRESHOLD,
        cell_flow_threshold=REFINED_CELL_FLOW_THRESHOLD,
        marker_cellprob_threshold=REFINED_MARKER_CELLPROB_THRESHOLD,
        marker_flow_threshold=REFINED_MARKER_FLOW_THRESHOLD,
        optional_region_cellprob_threshold=REFINED_OPTIONAL_REGION_CELLPROB_THRESHOLD,
        optional_region_flow_threshold=REFINED_OPTIONAL_REGION_FLOW_THRESHOLD,
        optional_region_result=None)

    print("Projected refinement finished. "
          f"Overview rows: {len(run_result.tables.overview)}, "
          f"summary rows: {len(run_result.tables.summary)}.")
    
    if RUNTIME_CONFIG.open_results:
        result_viewer = show_analysis_results(
            loaded_images=loaded_images,
            roi_labels_2d=roi_labels_2d,
            run_result=run_result,
            display_names=DISPLAY_NAMES,
            optional_region_result=None,
            viewer=result_viewer,
            layers_to_show=REFINEMENT_RESULT_LAYER_KEYS,
            replace_existing_layers=True,
            show_optional_region_image=True)

        print(f"Inspecting refined visualization for:\n{SELECTED_FILE_NAME}")
        napari.run()
else:
    print("Cached Cellpose refinement is disabled for this run.")
Zoom onto the analyzed ROI, showing the microglia channels and its segmented label layer after refinement.
Segmentation layer of the microglia channel after refinement, showing the Cellpose-segmented cell objects.

Refinement results for the microglia channel. Initially, the Cellpose segmentation of the microglia channel missed some cells in the center of the ROI. By activating the refinement block, we can recover some of these missed cells without having to rerun the full initial segmentation. The refinement uses cached Cellpose outputs to adjust the segmentation masks post hoc.

This refinement block serves two purposes:

  • it lets you tighten or relax Cellpose-derived masks without rerunning the full initial segmentation,

  • and it shows that the projected workflow can still be refined interactively.

In the current script:

  • the cell channel can be refined through cached Cellpose outputs,

  • the third channel can also be refined because it was initially segmented with Cellpose,

  • the marker channel keeps its original Otsu-based segmentation in the current demo, because threshold-based channels do not provide Cellpose refinement caches.

Important settings

REFINE_WITH_CACHED_CELLPOSE_OUTPUTS

master switch for the refinement step.

REFINEMENT_ANALYSIS_Z_CROP

optional z interval that is applied before projection for the refinement run. If left as None, the original projection span is reused.

REFINED_*_CELLPROB_THRESHOLD and REFINED_*_FLOW_THRESHOLD

Cellpose threshold parameters for the channels that were initially segmented with Cellpose.

REFINED_*_POSTFILTERS

optional post hoc filters such as "min_intensity", "local_contrast", or "bright_pixel_support". In this projected demonstration script they are left at None by default.

The block already contains the full parameter structure, so you can activate these filters later without having to rewrite the workflow.

Optionally reanalyze manually edited label layers from napari

If you edit the label layers manually in napari, the next cell can rebuild the tables from your edited masks:

REANALYZE_EDITED_LABELS_FROM_VIEWER = False

if REANALYZE_EDITED_LABELS_FROM_VIEWER:
    cell_masks_from_viewer, marker_masks_from_viewer = extract_label_masks_from_viewer(result_viewer)
    run_result = analyze_existing_masks(
        loaded_images=loaded_images,
        roi_labels_2d=roi_labels_2d,
        cell_masks=cell_masks_from_viewer,
        marker_masks=marker_masks_from_viewer,
        colocalization_config=COLOCALIZATION_CONFIG,
        optional_region_result=None,
        optional_region_masks=run_result.optional_region_masks,
        analysis_z_bounds=run_result.analysis_z_bounds,
        cell_refinement_context=run_result.cell_refinement_context,
        marker_refinement_context=run_result.marker_refinement_context,
        optional_region_refinement_context=run_result.optional_region_refinement_context)

    result_viewer = show_analysis_results(
        loaded_images=loaded_images,
        roi_labels_2d=roi_labels_2d,
        run_result=run_result,
        display_names=DISPLAY_NAMES,
        optional_region_result=None,
        viewer=result_viewer,
        layers_to_show=REFINEMENT_RESULT_LAYER_KEYS,
        replace_existing_layers=True,
        show_optional_region_image=True)
    print("Inspect the relabeled result in napari and close the window when finished.")
    napari.run()
else:
    print("Manual label reanalysis from the napari viewer is disabled for this run.")

roi_display_labels = np.broadcast_to(roi_labels_2d, loaded_images.cell_image.shape).copy()

This is useful when:

  • you want to merge or split labels manually using napari’s label editing tools,

  • you want to remove obvious segmentation artifacts by hand,

  • or you want to produce a final polished result table from curated masks.

The reanalysis uses the currently displayed label layers from the open napari viewer rather than loading masks from disk.

Visualize cells positive for channel 0 + channel 1

The next cell creates a dedicated positivity view for channel 0 against channel 1:

summary_channel01 = run_result.tables.summary.copy()
summary_channel01["marker_positive"] = summary_channel01["marker_positive"].astype(bool)
channel01_positive_masks = build_positive_cell_mask(run_result.cell_masks, summary_channel01)

viewer_01 = napari.Viewer()
viewer_01.add_image(
    loaded_images.cell_image,
    name=DISPLAY_NAMES.cell,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="magenta")
viewer_01.add_image(
    loaded_images.marker_image,
    name=DISPLAY_NAMES.marker,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="cyan")
viewer_01.add_labels(
    roi_display_labels,
    name="ROIs",
    scale=loaded_images.voxel_scale_zyx,
    opacity=0.35)
viewer_01.add_labels(
    channel01_positive_masks,
    name="Cells positive for channel 0 + channel 1",
    scale=loaded_images.voxel_scale_zyx,
    blending="additive")
print("Inspect channel-0 plus channel-1 positive cells.")
napari.run()
Zoom onto the analyzed ROI, showing the microglia, Iba1, and the resulting segmentation layer of the microglia cells that are positive for Iba1.
The microglia channel only.
The Iba1 channel only.
Segmentation layer of the Iba1-positive cells. This view shows which microglia cells are positive for the Iba1 marker, based on the projected analysis and segmentation results.

Top: Zoom onto the analyzed ROI, showing the microglia, Iba1, and the resulting segmentation layer of the microglia cells that are positive for Iba1. Center top: The microglia channel only. Center bottom: The Iba1 channel only. Bottom: Segmentation layer of the Iba1-positive cells. This view shows which microglia cells are positive for the Iba1 marker, based on the projected analysis and segmentation results.

This view shows:

  • the projected cell image,

  • the projected marker image,

  • the ROI layer,

  • and only those channel-0 cell masks that are positive against channel 1.

Visualize cells positive for channel 0 + channel 2

The following cell creates the dedicated positivity view for channel 0 against channel 2:

summary_channel02 = run_result.tables.summary.copy()
summary_channel02["marker_positive"] = summary_channel02["optional_region_positive"].astype(bool)
channel02_positive_masks = build_positive_cell_mask(run_result.cell_masks, summary_channel02)

viewer_02 = napari.Viewer()
viewer_02.add_image(
    loaded_images.cell_image,
    name=DISPLAY_NAMES.cell,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="magenta")
viewer_02.add_image(
    loaded_images.optional_region_image,
    name=DISPLAY_NAMES.optional_region,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="yellow")
viewer_02.add_labels(
    roi_display_labels,
    name="ROIs",
    scale=loaded_images.voxel_scale_zyx,
    opacity=0.35)
viewer_02.add_labels(
    channel02_positive_masks,
    name="Cells positive for channel 0 + channel 2",
    scale=loaded_images.voxel_scale_zyx,
    blending="additive")
print("Inspect channel-0 plus channel-2 positive cells.")
napari.run()
Zoom onto the analyzed ROI, showing the microglia, DAPI, and the resulting segmentation layer of the microglia cells that are positive for DAPI.
The microglia channel only.
The DAPI channel only.
Segmentation layer of the DAPI-positive cells. This view shows which microglia cells are positive for the DAPI marker, based on the projected analysis and segmentation results.

Top: Zoom onto the analyzed ROI, showing the microglia, DAPI, and the resulting segmentation layer of the microglia cells that are positive for DAPI. Center top: The microglia channel only. Center bottom: The DAPI channel only. Bottom: Segmentation layer of the DAPI-positive cells. This view shows which microglia cells are positive for the DAPI marker, based on the projected analysis and segmentation results.

This is the optional-third-channel analogue of the previous view.

Visualize cells positive for channel 0 + channel 1 + channel 2

The next cell creates the combined double-positive view:

summary_channel012 = run_result.tables.summary.copy()
summary_channel012["marker_positive"] = summary_channel012["marker_and_optional_region_positive"].astype(bool)
channel012_positive_masks = build_positive_cell_mask(run_result.cell_masks, summary_channel012)

viewer_012 = napari.Viewer()
viewer_012.add_image(
    loaded_images.cell_image,
    name=DISPLAY_NAMES.cell,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="magenta")
viewer_012.add_image(
    loaded_images.marker_image,
    name=DISPLAY_NAMES.marker,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="cyan")
viewer_012.add_image(
    loaded_images.optional_region_image,
    name=DISPLAY_NAMES.optional_region,
    scale=loaded_images.voxel_scale_zyx,
    blending="additive",
    colormap="yellow")
viewer_012.add_labels(
    roi_display_labels,
    name="ROIs",
    scale=loaded_images.voxel_scale_zyx,
    opacity=0.35)
viewer_012.add_labels(
    channel012_positive_masks,
    name="Cells positive for channel 0 + channel 1 + channel 2",
    scale=loaded_images.voxel_scale_zyx,
    blending="additive")
print("Inspect channel-0 plus channel-1 plus channel-2 positive cells.")
napari.run()
Zoom onto the analyzed ROI, showing the microglia, DAPI, and the resulting segmentation layer of the microglia cells that are positive for DAPI.
Segmentation layer of the Iba1- and DAPI-positive cells.

Top: Zoom onto the analyzed ROI, showing the microglia, Iba1, DAPI, and the resulting segmentation layer of the microglia cells that are positive for both Iba1 and DAPI. Bottom: Segmentation layer of the Iba1- and DAPI-positive cells.

This final positivity view is often the most biologically restrictive one. It shows only those channel-0 cells that satisfy both positivity conditions at the same time.

Export results

The last cell exports the final run result:

export_analysis_outputs(
    run_result=run_result,
    paths=loaded_images.paths,
    optional_region_result=None)
print("Final results exported.")

This writes the standard CellColoc outputs to the results/ directory associated with the current input file, including:

  • exported masks,

  • ROI masks,

  • summary tables,

  • and the Excel workbook with the main analysis results.

At this point, the projected three-channel workflow is complete.