TMA de-arraying library for spatial transcriptomics.
Assigns cells in an AnnData object to TMA cores on a grid, labelling each cell
with a grid position like R1_C1, R2_C3, etc.
Two methods are available:
| Method | Input | How it works |
|---|---|---|
| coord | Cell x/y coordinates | HDBSCAN clustering → connected-component merging → 1-D density peak detection to find grid centers |
| polygon | Segmented cores, e.g. QuPath (GeoDataFrame) | Area filtering → DBSCAN fragment merging → KMeans grid assignment → spatial join |
pip install git+https://github.com/pakiessling/ToMAto.git
# For the polygon method (requires geopandas + scikit-learn):
pip install "git+https://github.com/pakiessling/ToMAto.git#egg=tomato[geometry]"import tomato
# Coordinate-based de-arraying
tomato.dearray(adata, method="coord", num_rows=7, num_cols=7)
# Polygon-based de-arraying
tomato.dearray(adata, method="polygon", gdf=core_polygons, num_rows=7, num_cols=7)
# Result: adata.obs["core"] contains "R1_C1", "R2_C3", "unassigned", etc.Both methods need one 2-D coordinate per cell, supplied either way:
# from adata.obs columns (default: "x_centroid" / "y_centroid")
tomato.dearray(adata, method="coord", x_col="x_centroid", y_col="y_centroid")
# or from an (n, 2+) array in adata.obsm
tomato.dearray(adata, method="coord", obsm_key="spatial")coord is unit-agnostic — it works in whatever space you give it. But its
tuning parameters are expressed in those same units, so peak_prominence,
peak_distance, buffer_distance and max_distance all have to match. Defaults
are tuned for micrometer-scale Xenium coordinates.
polygon is not unit-agnostic. expected_diameter_px sizes the area filter in
pixels, so the polygons must be in pixel space — which is what QuPath exports.
Cell coordinates, however, are often in micrometers, and only the obsm_key path
converts them:
| Coordinates passed via | Conversion applied |
|---|---|
x_col / y_col |
none — used as-is |
obsm_key |
divided by pixel_size (µm → px) |
So for pixel-space polygons and micrometer cell coordinates, route the
coordinates through obsm_key and set pixel_size (0.2125 µm/px for Xenium):
adata.obsm["spatial"] = coords_in_micrometers
tomato.dearray(
adata,
method="polygon",
gdf=cores,
num_rows=7,
num_cols=7,
obsm_key="spatial",
pixel_size=0.2125,
expected_diameter_px=1155,
)Getting this wrong fails silently: the cells land far outside every polygon and
almost everything comes back "unassigned". If that happens, check the units
before touching anything else.
Use invert_y=True if the image origin and the coordinate origin disagree about
which way y grows.
A GeoDataFrame of core outlines — the tissue pieces themselves, as produced by
a foreground/background tissue segmentation. These are not per-cell segmentation
boundaries; cell polygons are ~1000× too small and are removed by the area filter,
after which the pipeline fails inside DBSCAN with Found array with 0 sample(s).
Only the geometry column is read. Any other columns are dropped, so attach core
metadata after de-arraying, not before. Fragments of a torn core and cores with
holes are both fine — DBSCAN merges fragments and a convex hull closes them.
Area thresholds derive from expected_diameter_px, so set it to your actual core
diameter. At the default 1155 px:
| Threshold | |
|---|---|
| implied core area | 1,047,741 px² |
| fragment kept if area above | 52,387 px² (min_area_fraction=0.05) |
| merged core kept if area above | 209,548 px² |
Loading a QuPath export:
import geopandas as gpd
gdf = gpd.read_file("annotations.geojson").set_crs(None, allow_override=True)The set_crs call matters: read_file stamps a plain GeoJSON as EPSG:4326, which
treats your pixel coordinates as longitude/latitude. Results still come out right,
but you get a stream of Geometry is in a geographic CRS warnings.
All parameters are passed to tomato.dearray().
| Parameter | Default | What it does |
|---|---|---|
method |
"coord" |
"coord" or "polygon" |
gdf |
None |
Core polygons; required when method="polygon" |
num_rows, num_cols |
7 |
Expected grid dimensions |
x_col, y_col |
"x_centroid", "y_centroid" |
Coordinate columns in adata.obs |
obsm_key |
None |
Read coordinates from adata.obsm[key] instead |
core_label_col |
"core" |
Output column written to adata.obs |
Units follow your input coordinates (see above).
| Parameter | Default | What it does |
|---|---|---|
min_cluster_size |
20 |
HDBSCAN minimum cluster size. Raise to suppress small spurious clusters, lower for sparse tissue |
min_samples |
7 |
HDBSCAN conservativeness. Higher declares more points noise |
buffer_distance |
None |
Connection radius for grouping cells into components. Falls back to adata.uns["median_nn_distance"], so run compute_median_nn_distance first or set it explicitly |
peak_prominence |
50 |
How far a density peak must rise above its surroundings, in cell counts, to count as a grid line. The most common thing to tune — too high raises No peaks found in x axis |
peak_distance |
50 |
Minimum separation between adjacent peaks, in histogram bins |
density_bins |
1000 |
Histogram resolution for peak detection. Lower it for sparse data so peaks are not smeared across bins |
min_cells_per_center |
10 |
Grid centers with fewer cells are discarded as spurious |
max_distance |
None |
Cutoff for reassigning leftover cells to the nearest center. Defaults to half the median spacing between detected centers |
Peak detection histograms the cell coordinates on each axis and calls
scipy.signal.find_peaks, so peak_prominence is in cell counts and
peak_distance is in histogram bins. If you hit No peaks found in x axis,
lower peak_prominence and density_bins together — with few cells per core,
1000 bins spreads them too thin to clear the default prominence.
assign_cells_to_cores() additionally accepts density_bins_x /
density_bins_y to set the two axes independently; dearray() does not expose
them.
| Parameter | Default | What it does |
|---|---|---|
expected_diameter_px |
1155 |
Core diameter in pixels. Drives every area threshold, so set it to your real core size |
min_area_fraction |
0.05 |
Keep fragments larger than this fraction of the expected core area. Lower to retain smaller fragments, raise to discard more debris |
pixel_size |
0.2125 |
Micrometers per pixel; applied only on the obsm_key path |
invert_y |
False |
Flip y against the polygon extent when the origins disagree |
Main entry point. Dispatches to the coord or polygon pipeline.
compute_median_nn_distance(adata)— median nearest-neighbor distancefind_connected_components(adata)— group nearby cells into componentsassign_cells_hdbscan(adata)— HDBSCAN clusteringmerge_clusters_by_component(adata)— merge clusters sharing a componentassign_cells_to_cores(adata)— peak detection grid assignmentreassign_unassigned_to_nearest_center(adata)— mop up unassigned cells
clean_and_merge_fragments(gdf)— filter noise, merge nearby fragmentsdearray_grid(gdf, n_rows, n_cols)— assign row/col indices via KMeansassign_cells_to_cores_polygon(adata, gdf)— spatial join cells to cores
uv sync --all-extras
uv run pytest
uv run ruff check
uv run ruff format --checkMIT, except tomato/_coord.py, which is derived from the
stile project
(Copyright 2025 Harsh Sinha) and licensed under the Apache License, Version 2.0.
See LICENSE, LICENSE-APACHE, and NOTICE.