1. From CellProfiler to AnnData#
CellProfiler writes one CSV per object plus an Image.csv. mantispy joins those into a
single AnnData and parses what every column means: which object it describes, which
measurement family it belongs to and which channel it was measured in.
Later steps, such as selecting intensity features, grouping a correlation heatmap or building feature sets, read this annotation instead of re-parsing names, so it helps to know what ends up where.
import tempfile
import mantispy as mt
An example export#
The cell below writes a small export, so this page runs without a download and shows what
CellProfiler hands you. Point read_profiles at a real ExportToSpreadsheet directory the
same way.
from pathlib import Path
import numpy as np
import pandas as pd
directory = Path(tempfile.mkdtemp()) / "export"
directory.mkdir(parents=True)
n_images, n_cells, channels = 4, 6, ["DNA", "ER"]
rng = np.random.default_rng(0)
image = pd.DataFrame(
{
"ImageNumber": np.arange(1, n_images + 1),
"Metadata_Plate": "P1",
"Metadata_Well": ["A01", "A01", "A02", "A02"],
"Metadata_Site": [1, 2, 1, 2],
"Count_Cells": n_cells,
}
)
for channel in channels:
image[f"ImageQuality_FocusScore_{channel}"] = rng.normal(0.5, 0.05, n_images)
image[f"ImageQuality_PowerLogLogSlope_{channel}"] = rng.normal(-2.0, 0.1, n_images)
image[f"FileName_{channel}"] = [f"img{i}_{channel}.tif" for i in range(n_images)]
image_no = np.repeat(np.arange(1, n_images + 1), n_cells)
object_no = np.tile(np.arange(1, n_cells + 1), n_images)
rows = len(image_no)
cells = pd.DataFrame({"ImageNumber": image_no, "ObjectNumber": object_no})
cells["AreaShape_Area"] = rng.normal(500, 50, rows)
cells["AreaShape_Perimeter"] = rng.normal(90, 5, rows)
for channel in channels:
cells[f"Intensity_MeanIntensity_{channel}"] = rng.normal(0.3, 0.05, rows)
cells[f"Texture_Contrast_{channel}_3_00_256"] = rng.normal(1.0, 0.2, rows)
cells["Location_Center_X"] = rng.uniform(0, 1024, rows)
cells["Location_Center_Y"] = rng.uniform(0, 1024, rows)
cells["Number_Object_Number"] = object_no
cells["Children_Nuclei_Count"] = 1
nuclei = pd.DataFrame({"ImageNumber": image_no, "ObjectNumber": object_no, "Parent_Cells": object_no})
nuclei["AreaShape_Area"] = rng.normal(200, 20, rows)
for channel in channels:
nuclei[f"Intensity_MeanIntensity_{channel}"] = rng.normal(0.4, 0.05, rows)
for name, frame in (("Image", image), ("Cells", cells), ("Nuclei", nuclei)):
frame.to_csv(directory / f"{name}.csv", index=False)
sorted(path.name for path in directory.iterdir())
['Cells.csv', 'Image.csv', 'Nuclei.csv']
Cells.csv and Nuclei.csv hold one row per object. Image.csv holds one row per field
of view: the plate and well it came from, the file names of each channel, and the
MeasureImageQuality statistics.
Reading#
primary_object sets what a row of the result is, here one cell. Other objects are joined
onto it. platemap is a CSV mapping wells to treatments, and annotate_controls marks the
wells that are negative controls, which several later steps use.
import pandas as pd
platemap = pd.DataFrame(
{
"Metadata_Well": ["A01", "A02"],
"Metadata_Perturbation": ["DMSO", "compound_a"],
}
)
adata = mt.io.read_profiles(directory, platemap=platemap)
mt.pp.annotate_controls(adata, negcon=("DMSO",))
adata
AnnData object with n_obs × n_vars = 24 × 9
obs: 'Metadata_ImageNumber', 'Metadata_ObjectNumber', 'Metadata_Plate', 'Metadata_Well', 'Metadata_Site', 'Metadata_Center_X', 'Metadata_Center_Y', 'Metadata_Perturbation', 'Metadata_Control'
var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature'
uns: 'mantispy'
layers: None (.X)
What landed where#
X holds the measurements, one row per cell, as float32.
adata.X.shape, adata.X.dtype
((24, 9), dtype('float32'))
obs holds the columns that identify where a cell came from, all with the Metadata_ prefix.
Metadata_Center_X and Metadata_Center_Y are object centroids. They are not profile
features, but QC uses them to find cells on the edge of a field, so they are kept here.
adata.obs.head()
| Metadata_ImageNumber | Metadata_ObjectNumber | Metadata_Plate | Metadata_Well | Metadata_Site | Metadata_Center_X | Metadata_Center_Y | Metadata_Perturbation | Metadata_Control | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 1 | P1 | A01 | 1 | 999.897418 | 932.101905 | DMSO | True |
| 1 | 1 | 2 | P1 | A01 | 1 | 794.307777 | 574.874386 | DMSO | True |
| 2 | 1 | 3 | P1 | A01 | 1 | 316.269939 | 592.215977 | DMSO | True |
| 3 | 1 | 4 | P1 | A01 | 1 | 276.312868 | 198.788887 | DMSO | True |
| 4 | 1 | 5 | P1 | A01 | 1 | 883.835089 | 538.646783 | DMSO | True |
var is the parsed annotation, with one row per feature describing what it measures.
adata.var.head()
| object | feature_group | feature | channel | scale | angle | gray_levels | radial_bin | params | is_feature | |
|---|---|---|---|---|---|---|---|---|---|---|
| Cells_AreaShape_Area | Cells | AreaShape | Area | NaN | NaN | NaN | NaN | NaN | NaN | True |
| Cells_AreaShape_Perimeter | Cells | AreaShape | Perimeter | NaN | NaN | NaN | NaN | NaN | NaN | True |
| Cells_Intensity_MeanIntensity_DNA | Cells | Intensity | MeanIntensity | DNA | NaN | NaN | NaN | NaN | NaN | True |
| Cells_Texture_Contrast_DNA_3_00_256 | Cells | Texture | Contrast | DNA | 3.0 | 0.0 | 256.0 | NaN | 3_00_256 | True |
| Cells_Intensity_MeanIntensity_ER | Cells | Intensity | MeanIntensity | ER | NaN | NaN | NaN | NaN | NaN | True |
Read a few of those names against the table:
Cells_AreaShape_Area: objectCells, groupAreaShape, no channel (geometry).Cells_Intensity_MeanIntensity_DNA: measured in theDNAchannel.Cells_Texture_Contrast_DNA_3_00_256: texture features also carry a scale, an angle and a gray-level count, each parsed into its own column.
Channel names are not hardcoded. They are read from the FileName_<channel> columns of
Image.csv, so a two-channel assay with channels called Hoechst and GFP parses the same
way as a five-channel Cell Painting run [Bray et al., 2016].
adata.uns["mantispy"]["channels"]
['DNA', 'ER']
Columns that are not features#
CellProfiler also writes columns that are not measurements: object numbers, parent links,
child counts and locations. The parser marks them is_feature = False and keeps them out of
X.
from mantispy._core.features import parse_feature_names
parse_feature_names(
[
"Cells_AreaShape_Area",
"Cells_Location_Center_X",
"Nuclei_Parent_Cells",
"Cells_Children_Nuclei_Count",
]
)[["object", "feature_group", "feature", "is_feature"]]
| object | feature_group | feature | is_feature | |
|---|---|---|---|---|
| Cells_AreaShape_Area | Cells | AreaShape | Area | True |
| Cells_Location_Center_X | Cells | Location | NaN | False |
| Nuclei_Parent_Cells | Nuclei | Parent | NaN | False |
| Cells_Children_Nuclei_Count | Cells | Children | NaN | False |
How objects are joined#
Nuclei measurements are joined onto their parent cell instead of being added as extra rows,
so one row still means one cell and Nuclei_AreaShape_Area sits next to
Cells_AreaShape_Area.
CellProfiler stores the link between objects on either table: Parent_Nuclei in Cells.csv
or Parent_Cells in Nuclei.csv. Both are handled. If neither is present, the reader raises
an error. Matching on object number instead would pair unrelated objects that happen to share
a number, without any warning.
By default the join must be one-to-one. A cell with two nuclei, or none, raises an error unless
you pass strict_one_to_one=False.
sorted(name for name in adata.var_names if name.startswith("Nuclei"))[:4]
['Nuclei_AreaShape_Area',
'Nuclei_Intensity_MeanIntensity_DNA',
'Nuclei_Intensity_MeanIntensity_ER']
Image quality#
MeasureImageQuality describes a field of view rather than a cell, so it does not belong in
X. It is stored separately, keyed by image, together with the plate and well, so that image
QC can set thresholds per plate instead of pooling all plates.
adata.uns["mantispy"]["image_table"]
| ImageQuality_FocusScore_DNA | ImageQuality_PowerLogLogSlope_DNA | ImageQuality_FocusScore_ER | ImageQuality_PowerLogLogSlope_ER | Metadata_Plate | Metadata_Well | Metadata_Site | |
|---|---|---|---|---|---|---|---|
| ImageNumber | |||||||
| 1 | 0.506287 | -2.053567 | 0.464813 | -2.232503 | P1 | A01 | 1 |
| 2 | 0.493395 | -1.963840 | 0.436729 | -2.021879 | P1 | A01 | 2 |
| 3 | 0.532021 | -1.869600 | 0.468836 | -2.124591 | P1 | A02 | 1 |
| 4 | 0.505245 | -1.905292 | 0.502066 | -2.073227 | P1 | A02 | 2 |
Checking the contract#
validate returns a report instead of raising, so you can inspect a partly formed object.
report = mt.io.validate(adata)
report.ok
True
broken = adata.copy()
broken.obs = broken.obs.drop(columns="Metadata_Plate")
print(mt.io.validate(broken))
ERROR: obs is missing required column 'Metadata_Plate' (resolution 'cell')
Writing#
write validates first, then writes h5ad (or zarr for a .zarr suffix). The schema
version is stored in the file and checked on read, so a file written by a future
incompatible version raises an error instead of loading incorrectly.
path = Path(tempfile.mkdtemp()) / "cells.h5ad"
mt.io.write(adata, path)
mt.io.read(path)
AnnData object with n_obs × n_vars = 24 × 9
obs: 'Metadata_ImageNumber', 'Metadata_ObjectNumber', 'Metadata_Plate', 'Metadata_Well', 'Metadata_Site', 'Metadata_Center_X', 'Metadata_Center_Y', 'Metadata_Perturbation', 'Metadata_Control'
var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature'
uns: 'mantispy'
layers: None (.X)
Already have profiles?#
read_profiles also reads well-level tables from pycytominer [Serrano et al., 2025], CytoTable
or the Cell Painting Gallery [Weisbart et al., 2024]. It handles what differs between real
datasets: four spellings of the metadata prefix, missing-value sentinels, files with different
columns, and metadata that only exists in the directory name.
Next: 2. From cells to profiles.