Core concepts and cheatsheet#
This page summarizes how spatialproteomics is organized and how its API is meant to be used. It is a compact reference for both humans and AI coding assistants; the tutorials cover each topic in depth.
The data model#
A spatialproteomics object is a plain xarray.Dataset.
All data belonging to one sample lives in this single object as named data variables (“layers”) that share dimensions:
Layer ( |
Dimensions |
Content |
|---|---|---|
|
|
Multiplexed image; one channel per marker. |
|
|
Integer cell mask; |
|
|
Per-cell observations (centroids, area, cell type, neighborhood). |
|
|
Expression matrix produced by |
|
|
Cell type names and colors. |
|
|
Cell type composition of each cell’s neighborhood. |
|
|
Neighborhood names and colors. |
You can add layers of your own with ds.pp.add_layer(array, key_added="_name").
A common one is _raw_image: an untouched copy of _image kept before thresholding,
because pp.threshold overwrites _image (see the workflow and examples below).
Because these layers share coordinates, subsetting is synchronized:
cropping the image also crops the segmentation and drops cells outside the crop from _obs and _intensity;
selecting a cell type removes the other cells from the mask and all tables.
Use the constants in sp.Layers, sp.Dims, sp.Features and sp.Props instead of hard-coding strings,
e.g. sp.Features.LABELS == "_labels" (the cell type column in _obs), sp.Features.X == "centroid-1".
Accessors#
Importing spatialproteomics registers five accessors on every xarray.Dataset:
Name |
Class |
Responsibility |
|---|---|---|
|
PreprocessingAccessor |
Subsetting, image processing, segmentation handling, quantification, tables. |
|
ToolAccessor |
Wrappers around external tools (cellpose, stardist, astir, spotiflow) and export. |
|
LabelAccessor |
Cell type labels: prediction, naming, colors, subtype hierarchies. |
|
NeighborhoodAccessor |
Cellular neighborhoods and graph features. |
|
PlotAccessor |
Plotting of images, masks, labels, neighborhoods and observations. |
Every accessor method returns a new dataset; nothing is modified in place.
Always reassign (ds = ds.pp.threshold(...)) or chain calls:
ds = ds.pp.threshold(quantile=0.5).pp.add_quantification().pp.transform_expression_matrix()
Indexing#
ds.pp["DAPI"] # one channel
ds.pp[["CD4", "CD8"]] # several channels
ds.pp[100:200, 300:400] # spatial crop (x slice, y slice); cells outside are removed
ds.pp[["CD4", "CD8"], 100:200, 300:400]
ds.la["B cell"] # cells of one type (by name or integer label id)
ds.la[["B cell", "T cell"]]
ds.la.deselect(["B cell"]) # all cells except these types
ds.nh["Neighborhood 1"] # cells of one neighborhood
Canonical workflow (xarray backend)#
import spatialproteomics as sp
import xarray as xr
from skimage.io import imread
# 1. create the object from a (channels, y, x) array
image = imread("input.tiff")
ds = sp.load_image_data(image, channel_coords=["DAPI", "PAX5", "CD3", "CD4", "CD8"])
# 2. segmentation (requires `pip install cellpose`); adds `_segmentation` and `_obs`
ds = ds.tl.cellpose(channel="DAPI")
ds = ds.pp.add_observations("area")
ds = ds.pp.filter_by_obs("area", func=lambda x: (x > 20) & (x < 150))
ds = ds.pp.grow_cells(iterations=2)
# 3. image processing: per-channel thresholding (overwrites `_image`)
# `quantile=` is relative to each channel's intensity distribution, `intensity=` is an absolute value;
# pass one value per channel, or one value plus `channels=[...]` to threshold only some channels.
# Thresholds require human inspection: plot the raw image, the thresholded image and the resulting
# cell types (step 5) side by side, as in the "Cell type QC plot" example below.
ds = ds.pp.add_layer(ds["_image"].values.copy(), key_added="_raw_image") # keep the original for comparison
ds = ds.pp.threshold(quantile=[0.2, 0.6, 0.6, 0.6, 0.6])
# 4. quantification; adds `_intensity`
ds = ds.pp.add_quantification(func="intensity_mean").pp.transform_expression_matrix(method="arcsinh")
# 5. cell types; maps marker -> cell type
ds = ds.la.predict_cell_types_argmax({"PAX5": "B cell", "CD3": "T cell"})
ds = ds.la.set_label_colors(["B cell", "T cell"], ["red", "green"])
# 6. binarization and subtypes; maps marker -> subtype
# quantify the fraction of positive (non-zero) pixels per cell on the thresholded image, then threshold
# that fraction; this adds a binary `{marker}_binarized` column to `_obs` (e.g. `CD4_binarized`).
# See the "Binarization QC plot" example below for checking the result visually.
ds = ds.pp.add_quantification(func=sp.percentage_positive, key_added="_percentage_positive")
ds = ds.la.threshold_labels({"CD4": 0.5, "CD8": 0.5}, layer_key="_percentage_positive")
ds = ds.la.predict_cell_subtypes({"T cell": {"CD4": "T_h", "CD8": "T_tox"}})
ds = ds.la.set_label_colors(["B cell", "T cell", "T_h", "T_tox"], ["red", "green", "yellow", "blue"])
# 7. plotting (all plotting functions accept `ax=`)
# plotting an image
ds.pp[["CD4", "CD8"]].pl.colorize(["red", "green"]).pl.show()
# plotting the raw image instead of the thresholded one
ds.pp[["CD4", "CD8"]].pl.colorize(["red", "green"], layer_key="_raw_image").pl.show()
# plotting a segmentation mask and the labels
ds.pl.show(render_image=False, render_segmentation=True, render_labels=True)
# 8. tables and export
obs = ds.pp.get_layer_as_df("_obs") # cell types decoded to names
expr = ds.pp.get_layer_as_df("_intensity")
ds.drop_encoding().to_zarr("sample.zarr") # reload with xr.open_zarr("sample.zarr")
adata = ds.tl.convert_to_anndata() # requires anndata
sdata = ds.tl.convert_to_spatialdata() # requires spatialdata
Other frequently used operations:
Segmentation from elsewhere:
ds.pp.add_segmentation(mask); alternatives to cellpose:ds.tl.stardist()Labels from elsewhere:
ds.la.add_labels_from_dataframe(df, cell_col="cell", label_col="label").Marker binarization and subtypes:
ds.la.threshold_labels({...})thends.la.predict_cell_subtypes({...}); switch levels withds.la.set_label_level(...).Neighborhoods across samples:
sp.ImageContainer({"s1": ds1, "s2": ds2}).compute_neighborhoods(k=5)computes compositions, clusters them with k-means and returns a dict of datasets with neighborhoods assigned (ds.nh["Neighborhood 0"]).Composable plotting:
ds.pl.colorize([...]).pl.render_segmentation().pl.render_labels().pl.imshow(); scatter plots withds.pl.scatter_labels()/ds.pl.scatter(feature).Large images:
ds.pp.downsample(rate=8)before plotting,xr.open_zarrfor lazy loading.
The spatialdata backend#
If you start from a spatialdata.SpatialData object (pip install "spatialproteomics[spatialdata]"),
use the module-level functions instead of the accessors.
They follow scverse conventions: they take the object as first argument and modify it in place unless copy=True.
import spatialproteomics as sp
sp.tl.cellpose(sdata, channel="DAPI") # writes sdata.labels["segmentation"]
sp.pp.threshold(sdata, quantile=[0.2, 0.6, 0.6, 0.6, 0.6])
sp.pp.add_quantification(sdata, func="intensity_mean") # writes sdata.tables["table"]
sp.pp.transform_expression_matrix(sdata, method="arcsinh")
sp.la.predict_cell_types_argmax(sdata, {"CD4": "T_h", "CD8": "T_tox"}) # marker -> cell type; adds obs["celltype"]
Convert between the two backends with sp.read_from_spatialdata(sdata) and ds.tl.convert_to_spatialdata().
The xarray backend offers the full feature set; the spatialdata backend covers the core pipeline.
Common pitfalls#
The accessors only exist after
import spatialproteomics, including on objects loaded withxr.open_zarr.Accessor methods return new objects.
ds.pp.threshold(quantile=0.5)without reassignment has no effect.pp.thresholdoverwrites_image. Keep a copy (_raw_image) first if you want to compare raw and thresholded images.la.predict_cell_types_argmaxandla.predict_cell_subtypestake marker -> cell type mappings, e.g.{"CD4": "T_h", "CD8": "T_tox", "FOXP3": "T_reg", "BCL6": "T_fh"}, not the reverse.ds.pp[a:b, c:d]slices x first, then y, while the image array is stored as(channels, y, x). Slices select by coordinate value (likexarray.Dataset.sel), so the end point is included.Call
drop_encoding()beforeto_zarr()to avoid xarray encoding errors.Optional tools must be installed separately:
cellpose,stardist,astirandspotiflow(or all at once withpip install "spatialproteomics[all]").Many methods require earlier steps: e.g.
la.predict_cell_types_argmaxneeds_intensity, which needs_segmentation. Inspect the object (print(ds)) to see which layers are present.
Examples#
Cell type QC plot#
Thresholding, argmax cell type prediction and a side-by-side plot of raw image, thresholded image and resulting cell types per marker, for a human to check the thresholds.
import matplotlib.pyplot as plt
import xarray as xr
import spatialproteomics # noqa: F401 (registers the .pp / .la / .pl accessors)
# --- Inputs (replace with your own) -----------------------------------------
# marker -> display color
marker_colors = {"CD3": "red", "CD20": "green", "CD68": "blue"}
# marker -> cell type it identifies
marker_to_celltype = {"CD3": "T cell", "CD20": "B cell", "CD68": "Macrophage"}
# cell type -> display color
celltype_colors = {"T cell": "red", "B cell": "green", "Macrophage": "blue"}
# channel -> absolute intensity threshold
thresholds = {"CD3": 0.3, "CD20": 0.25, "CD68": 0.4}
def load_dataset(zarr_path, markers):
"""Open a segmented zarr and keep an untouched copy of the image."""
ds = xr.open_zarr(zarr_path).pp[["DAPI"] + list(markers)]
return ds.pp.add_layer(ds["_image"].values.copy(), key_added="_raw_image")
def predict_cell_types(ds):
"""Threshold, quantify, transform and assign one cell type per cell."""
ds = (
ds.pp.threshold(intensity=list(thresholds.values()), channels=list(thresholds.keys()))
.pp.add_quantification(func="intensity_mean")
.pp.transform_expression_matrix(method="arcsinh")
.la.predict_cell_types_argmax(marker_to_celltype)
)
return ds.la.set_label_colors(celltype_colors.keys(), celltype_colors.values()).pl.autocrop()
def plot_markers_and_celltypes(ds, scaling=5):
"""Overview row, then one row per marker: raw + segmentation, raw, thresholded, cell type."""
markers, colors = list(marker_colors.keys()), list(marker_colors.values())
nrows, ncols = len(markers) + 1, 4
fig, axes = plt.subplots(nrows, ncols, figsize=(ncols * scaling, nrows * scaling))
# Overview row
ds.pp["DAPI"].pl.colorize("gold").pl.show(render_segmentation=True, ax=axes[0, 0])
ds.pp[markers].pl.colorize(colors, layer_key="_raw_image").pl.show(ax=axes[0, 1])
ds.pp[markers].pl.colorize(colors).pl.show(ax=axes[0, 2])
ds.pl.show(render_image=False, render_labels=True, ax=axes[0, 3])
for ax, title in zip(axes[0], ["Segmentation", "Raw", "Thresholded", "Cell types"]):
ax.set_title(title)
# One row per marker
for row, (marker, color) in zip(axes[1:], marker_colors.items()):
celltype = marker_to_celltype[marker]
raw = ds.pp[marker].pl.colorize(color, layer_key="_raw_image")
try:
# outline only the cells of the matching type
raw.la[celltype].pl.show(render_segmentation=True, ax=row[0])
except ValueError: # no cells of this type
raw.pl.show(ax=row[0])
raw.pl.show(ax=row[1])
ds.pp[marker].pl.colorize(color).pl.show(ax=row[2])
try:
ds.la[celltype].pl.show(render_image=False, render_labels=True, ax=row[3])
except ValueError:
pass # leave the panel empty
for ax, title in zip(row, [marker, marker, marker, celltype]):
ax.set_title(title)
for ax in axes.flat:
ax.axis("off")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
ds = load_dataset("path/to/sample.zarr", marker_colors.keys())
ds = predict_cell_types(ds)
plot_markers_and_celltypes(ds)
Binarization QC plot#
Binarizing markers per cell and plotting each step (raw image, pixel-thresholded image, cells called positive) for a human to check the two thresholds.
import matplotlib.pyplot as plt
import xarray as xr
import spatialproteomics as sp
# --- Inputs (replace with your own) -----------------------------------------
# marker -> (absolute pixel intensity threshold, minimum fraction of positive pixels per cell)
# Plotting order follows the order of this dict.
thresholds = {
"Ki-67": (0.3, 0.1),
"CD8": (0.25, 0.2),
"PD-1": (0.4, 0.15),
}
def load_dataset(zarr_path):
"""Open a segmented zarr and keep an untouched copy of the image."""
ds = xr.open_zarr(zarr_path)
ds = ds.pp.add_layer(ds["_image"].values.copy(), key_added="_raw_image")
return ds.pl.autocrop()
def binarize_marker(ds, marker, intensity_threshold, perc_pos_threshold):
"""Threshold pixels, compute % positive pixels per cell, then binarize cells."""
return (
ds.pp[marker]
.pp.threshold(intensity=intensity_threshold)
.pp.add_quantification(func=sp.percentage_positive, key_added="_percentage_positive")
.la.threshold_labels({marker: perc_pos_threshold}, layer_key="_percentage_positive")
)
def plot_binarization(ds, scaling=5):
"""One row per marker: raw image, thresholded image, positive cells outlined."""
nrows, ncols = len(thresholds), 3
fig, axes = plt.subplots(nrows, ncols, figsize=(ncols * scaling, nrows * scaling), squeeze=False)
for row, (marker, (intensity_thr, perc_pos_thr)) in zip(axes, thresholds.items()):
ds.pp[marker].pl.colorize("white", layer_key="_raw_image").pl.show(ax=row[0])
ds_bin = binarize_marker(ds, marker, intensity_thr, perc_pos_thr)
ds_bin.pp[marker].pl.colorize("white").pl.show(ax=row[1])
# keep only cells called positive (`{marker}_binarized == 1`) and outline them
(
ds_bin.pp[marker]
.pp.filter_by_obs(f"{marker}_binarized", func=lambda x: x == 1)
.pl.colorize("white")
.pl.show(
render_segmentation=True,
segmentation_kwargs={"alpha": 0.5, "colors": ["gold"]},
ax=row[2],
)
)
for ax, title in zip(row, ["Raw", "Thresholded", "Binarized"]):
ax.set_title(f"{marker} – {title}")
for ax in axes.flat:
ax.axis("off")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
ds = load_dataset("path/to/sample.zarr")
plot_binarization(ds)