Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ToMAto

Test

TMA de-arraying library for spatial transcriptomics.

What it does

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

Installation

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]"

Quickstart

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.

Input requirements

Cell coordinates

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")

Coordinate space: pixels vs micrometers

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.

Polygons (gdf)

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.

Parameters

All parameters are passed to tomato.dearray().

Shared

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

Coord method

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.

Polygon method

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

API

tomato.dearray(adata, method, ...)

Main entry point. Dispatches to the coord or polygon pipeline.

Coord functions

  • compute_median_nn_distance(adata) — median nearest-neighbor distance
  • find_connected_components(adata) — group nearby cells into components
  • assign_cells_hdbscan(adata) — HDBSCAN clustering
  • merge_clusters_by_component(adata) — merge clusters sharing a component
  • assign_cells_to_cores(adata) — peak detection grid assignment
  • reassign_unassigned_to_nearest_center(adata) — mop up unassigned cells

Polygon functions

  • clean_and_merge_fragments(gdf) — filter noise, merge nearby fragments
  • dearray_grid(gdf, n_rows, n_cols) — assign row/col indices via KMeans
  • assign_cells_to_cores_polygon(adata, gdf) — spatial join cells to cores

Development

uv sync --all-extras
uv run pytest
uv run ruff check
uv run ruff format --check

License

MIT, 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.

About

TMA de-arraying for spatial transcriptomics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages