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: object Cells, group AreaShape, no channel (geometry).

  • Cells_Intensity_MeanIntensity_DNA: measured in the DNA channel.

  • 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.