Xenium ingest
Load a 10x Xenium bundle into NimbusImage. Needs pip install 'nimbusimage[xenium]'.
Every step is also a subcommand of the nimbusimage-xenium command.
nimbusimage.xenium.bundle
Read a 10x Xenium output bundle (XOA 1.3-4) without touching the server.
The files a bundle holds, and the traps in reading them::
experiment.xenium run manifest; ``pixel_size`` (um/px)
cells.zarr.zip polygon_sets {0: nucleus, 1: cell} in MICRONS,
packed ``cell_id``
cell_feature_matrix.zarr.zip counts, GENE-MAJOR CSR (row = feature,
indices = cell); controls mixed in
analysis.zarr.zip ``cell_groups`` clusterings only, ZERO-PADDED
morphology_focus/*.ome.tif one logical multi-channel image, one file per
channel; stain names only in the OME XML
transcripts.zarr.zip molecule pyramid, used as shipped
XeniumBundle
An extracted Xenium output directory.
Example
bundle = XeniumBundle("extracted/") bundle.pixel_size # 0.2125 n_vertices, vertices = bundle.polygons() labels = bundle.cell_groups() # {"graphclust": [...], ...}
pixel_size
cached
property
pixel_size (um/px) from experiment.xenium — the scale every
micron-to-pixel transform uses (not whatever NimbusImage reports).
require(*paths)
Raise XeniumError naming every missing bundle file.
Steps call this before any server work, so a mistyped
--bundle-dir fails at once instead of after an upload.
polygons(polygon_set=CELL_POLYGON_SET)
(num_vertices[N], vertices[N, 2*maxV]) in MICRONS.
first_vertices(cells=None, *, count=1)
[K, 2*count] leading vertices (MICRONS, x0 y0 x1 y1 ...) of
each CELL polygon, or of the cells listed. Reads only those
columns, not the whole padded vertex array.
vertex_counts()
Vertices per CELL polygon (without reading the vertices).
cell_index_by_id()
Packed zarr cell_id -> cell_index.
cell_types(cell_types_csv, *, complete=True)
Group label per cell_index from *_cell_types.csv.
Rows are joined on the decoded cell_id; row order is not trusted.
A duplicate row always raises (which label would win?). With
complete (the default) a missing cell raises too; otherwise
missing cells are None.
cell_groups()
Clusterings from analysis.zarr.zip (see decode_cell_groups).
gene_rows(symbols)
{symbol: matrix row} for a gene panel, validated.
Every symbol must name a feature_type == "gene" row: development
panels omit canonical markers, so a missing one raises rather than
silently uploading nothing. Protein panels reuse gene names (CD3E
gene and CD3E antibody), so only gene rows are searched.
gene_counts(symbols)
{symbol: (cell_indices, counts)} for a gene panel.
Gene-major CSR makes one gene across all cells one contiguous slice,
so a panel reads without the full matrix. See gene_rows for the
validation.
features(feature_types=('gene', 'protein'))
(rows, symbols, names, feature_ids, kinds) for the matrix rows
of feature_types. names are the bundle's (proteins named
"<key> (protein)"); symbols the stored form
(safe_symbol), which must stay unique.
counts(feature_types=('gene', 'protein'))
(csc cells x features float32, symbols, feature_ids, kinds),
symbols in their stored form (see features).
The 10x matrix is gene-major CSR (row = feature, indices = cell). Read
as (data, indices, indptr) with shape (cells, features) it IS the
cells x features CSC matrix, so no transpose is materialized; dropping
control rows is a column selection on that CSC.
morphology_files()
morphology_focus/*.ome.tif in order (one file per channel).
morphology_channel_names()
Stain name per morphology file, from the OME metadata.
File names can't supply them: XOA 1-3 ships
morphology_focus_000N.ome.tif and XOA 4 ch00NN_<stain>.ome.tif,
which NimbusImage's filename parser splits on _.
safe_symbol(symbol)
A feature symbol as NimbusImage can store it.
Symbols become MongoDB keys (property sub-values, materialized genes),
which can't contain . or $; the SpatialPlugin refuses a table
that has them. Mouse panels do (Tex19.1), so both become _.
The table keeps the original name in var/feature_name.
open_zarr_zip(path)
A read-only zarr group over a .zarr.zip (zarr 2 or 3).
decode_cell_id(cell_id)
CSV 'aaaaadoa-1' -> zarr (992, 1).
The first 8 characters are nibbles a..p = 0..15; the suffix is the
dataset suffix.
decode_cell_groups(analysis_zarr, n_cells)
{grouping: labels[n_cells]} with 0 = unassigned, 1-based ids.
indices is ZERO-PADDED: unassigned cells occupy slots holding 0, so
cell 0 appears many times. A zero is genuine only as the FIRST element of
its block (blocks are strictly ascending). Decoding naively gives cell 0
the last cluster id of every grouping.
ome_channel_names(ome_xml, files)
Stain name per file from one file's OME XML.
Every file of a morphology set carries the whole multi-file description:
<Channel Name="DAPI"> per channel and TiffData FirstC ->
UUID FileName for which file holds it. Falls back to file order and
then to the file's stem.
staged_channel_file_name(index, stain)
c01-ATP1A1+CD45+E-Cadherin.ome.tif.
NimbusImage's filename parser splits on _ (and / is a path), so
those become +/-; the two-digit prefix keeps channel order, since
values sort as text.
nimbusimage.xenium.cells
CellMap: which annotation is which cell, on which dataset, drawn how.
Every per-cell step (gene panel, clusterings, UMAP, cell types, the spatial
table) joins bundle data to annotations by cell_index. The join is only
right if the ids belong to the dataset being written, were drawn from the
CELL polygons (nucleus order is not cell order), and cover exactly this
bundle's cells. CellMap carries those facts with the ids, and
CellMap.check is the one guard every per-cell step runs before writing.
Its frame records how the polygons were drawn (image, pixel size,
alignment), so later steps reuse it instead of being re-told — and the saved
file can be verified on its own.
CellMap
Annotation id per polygon, in bundle order, for one dataset.
ids[i] is the annotation drawn for polygon i of polygon_set,
or None where there is none (a degenerate polygon, or past --limit).
ids always has one slot per polygon, so a limited upload is still
indexed by cell_index.
indices(start=0, stop=None)
Cell indices in [start, stop) that have an annotation.
check_dataset(ds)
The map (and its frame) must be ds's.
check(bundle, ds=None)
The guard every per-cell step runs before writing anything.
save(path)
Write to path as-is (.npz content, no pickled objects).
read(path)
classmethod
A saved CellMap, or the bare id array of a pre-CellMap
--ids-out file (which records no dataset or frame).
A saved map is read without unpickling anything. Only a bare-id
.npy (an object array) needs pickle — read your own files only.
read_frame(path, ds)
The frame saved in ds's cells file — the one reader of a saved
frame. A missing file, a bare-id file (which saves no frame) or another
dataset's map is an error, never a silent fall back to a default.
fetch_cells(ds, bundle, frame, *, page=20000)
Re-derive a dataset's CellMap from the server by geometry.
Each polygon annotation is matched to the cell whose first two vertices
and vertex count frame puts at the same place — never by list
position, which the server does not promise. So other polygons in the
dataset (regions, nuclei) are ignored, a limited or partial upload maps
the cells it has (the rest are None), and a cell uploaded twice, or two
cells with the same geometry, is an error. frame is the one the
upload used.
verify_cells(ds, bundle, cells, *, sample=50, source='the cell map')
Spot-check sample cells against the server: each id must be an
annotation of ds whose first vertex is where cells.frame puts
the cell's. One request.
open_cells(ds, bundle, path=None, *, frame=None, sample=50)
The dataset's verified CellMap — the one way steps get one.
path holding a saved map: it is checked (right dataset, cells, this
bundle) and spot-checked against the server with the frame it was saved
with; a frame given as well must match it. path holding a bare
id array (written before CellMap existed): spot-checked with frame.
No file: re-derived by geometry with frame, then saved to path.
frame defaults to the bundle's morphology frame — safe here because
everything returned is verified against the server, so a wrong frame is
refused, never used.
nimbusimage.xenium.ingest
Load a Xenium bundle into NimbusImage datasets.
Each step takes a Dataset (or the client, to create one) and a
XeniumBundle. Two validated objects carry everything the steps share:
ImageFrame— how microns map onto the dataset's pixels (image, pixel size, alignment). Every coordinate-producing step takes one.CellMap— the annotation id of every cell, with the dataset and frame it belongs to.upload_polygonsreturns it;open_cellsloads or re-derives it, verified. Every per-cell step starts withcells.check(bundle, ds), so ids of another dataset, of nuclei or of another bundle never reach the server.
Inputs that come as files (CSV, .npy, GeoJSON) are converted and checked
by one function each, and every check runs before the step's first write.
load_embedding(embedding, n_cells)
A [n_cells, 2] embedding (row i = cell_index i) from an array or
.npy, shape-checked against the bundle's cell count.
upload_morphology(client, bundle, name, parent_folder_id=None, wait=True)
A dataset from morphology_focus/, channels named by stain.
Uploads the per-channel OME-TIFFs under staged names
(c00-DAPI.ome.tif, ...) and pins the channel axis to the one varying
filename token. Waits for the transcode job unless wait=False.
delete_tagged(ds, tag)
Delete every annotation carrying tag (for test uploads).
upload_polygons(ds, bundle, frame=None, *, polygon_set=CELL_POLYGON_SET, tags=None, batch=5000, limit=None, delete_tag=None)
Bulk-create the segmentation polygons in bundle order.
frame says how microns land on ds (default: the bundle's
morphology frame; ImageFrame.create(alignment=...) for the H&E
image). Polygons with fewer than three vertices are skipped.
delete_tag first removes the annotations of an earlier test upload —
only once every input has been read, so a bad input never deletes them
without uploading replacements.
Returns the CellMap — save it (.save(path)); every per-cell step
wants it. A nucleus upload returns one too, for display only: per-cell
steps refuse it, since nuclei are not in cell_index order.
ensure_property(ds, name, shape='polygon')
A client-side property (no worker) registered into the dataset's
collections. Idempotent: register() appends only where missing.
upload_gene_panel(ds, bundle, cells, genes, *, property_name='Gene Expression', dense=True, chunk=20000, limit=None, replace=False)
A marker panel as one nested property: {"MS4A1": 3, "CD3E": 0, ...}.
Never the whole matrix (4,624 genes x 700k cells is billions of values):
use build_spatial_table for that. dense writes explicit zeros so
the UI can tell zero from missing. Values are not overwritten on
re-submit (a silent no-op), so pass replace=True to re-run.
upload_clusters(ds, bundle, cells, *, property_name='Clustering', chunk=20000, limit=None, replace=False, labels=None)
Every clustering as one nested property:
{"graphclust": 12, "kmeans_2_clusters": 1, ...}, 0 = unassigned.
labels reuses a bundle.cell_groups() already read.
upload_umap(ds, bundle, cells, embedding, *, property_name='UMAP', chunk=20000, limit=None, replace=False)
A 2-D embedding (compute_umap) as {"x": ..., "y": ...}.
embedding (an array or .npy) must cover exactly this bundle's
cells (row i = cell_index i), which catches another run's file.
upload_cell_types(ds, bundle, cells, cell_types, *, base_tags=None, chunk=5000, limit=None, reset=False, verify_sample=20)
Tag each cell polygon base_tags + [group], e.g.
["cell", "Memory B Cell"].
Categorical data are tags, not property values. Tags are REPLACED on
every touched annotation; reset=True writes base_tags only, which
undoes this. A random sample is read back (the bulk update returns no
body). cell_types is the *_cell_types.csv path or the labels
bundle.cell_types returned. Returns the label counts.
build_spatial_table(bundle, cells, out, *, cell_types=None, umap=None)
Write the dataset's spatial.zarr.zip (AnnData, zarr v2, zipped).
::
X counts, cells x features, CSC
layers/X_csr the same counts, CSR, for per-cell reads
obs annotation_id (the ONLY join key), cell_index,
cell_type, one column per clustering
var index = symbol; gene_id; feature_type
obsm/X_umap from ``umap``, optional
uns/nimbus schemaVersion, datasetId, source bundle, created
uns/nimbus/datasetId is cells.dataset_id: upload the file to
that dataset. Cells without an annotation are dropped. Proteins are
features named "<key> (protein)". cell_types is a
*_cell_types.csv path or the labels bundle.cell_types returned.
Needs anndata and pandas.
spatial_table_dataset(path)
The dataset a spatial table was built for — its join keys are that
dataset's annotation ids. build_spatial_table records it as the
AnnData array uns/nimbus/datasetId; the SpatialPlugin's recompute
as the attribute uns.attrs["nimbus"]["datasetId"]. Both count.
upload_spatial_table(ds, path)
Upload and register a build_spatial_table file on ds — only
if the file was built for ds: the table records its dataset, and
its join keys match nothing anywhere else.
register_transcripts(ds, bundle, frame, *, item_id=None)
Upload transcripts.zarr.zip as shipped and register it.
The file is already a level-of-detail pyramid, so nothing is rebuilt;
registration records how molecules land on ds: frame's pixel
size and, for the H&E image, its inverse alignment. Pass the dataset's
CellMap (its polygons' frame, so molecules and cells line up) or an
explicit ImageFrame. item_id registers an item already in the
folder and skips the (slow) upload.
region_annotations(geojson, dataset_id, frame, *, drawn_in, tag=REGION_TAG)
Tagged polygon annotations from GeoJSON (a FeatureCollection, an
array of Features, one Feature or one geometry; see
geojson_features).
Each outer ring becomes one polygon tagged [<class>, tag, "region"]
("region" always kept, whatever tag is) — class first,
because GeoJSON export writes the first tag as QuPath's
classification. drawn_in is what the file's coordinates are
("he", "morphology" px or "microns"); frame is the
dataset's (see ImageFrame.region_transform).
upload_regions(ds, geojson, frame, *, drawn_in, alignment=None, tag=REGION_TAG)
Upload region polygons (e.g. a pathologist's layer, which 10x ships in H&E pixels) for the Region statistics dialog.
region is a reserved tag: spatial analyses never treat those polygons
as cells. frame is the dataset's CellMap or an explicit
ImageFrame. On the morphology image, regions drawn in H&E pixels
need an alignment: pass alignment to add it to a frame that lacks
one (one that differs from the frame's own is an error).
nimbusimage.xenium.geometry
How Xenium microns land on a NimbusImage dataset's pixels.
NimbusImage annotation coordinates are image pixels (origin top-left, +y
down). Xenium vertices and molecules are microns, and the H&E image sits on
its own pixel grid, related to the morphology grid by the bundle's
*_he_imagealignment.csv (a 3x3 affine, H&E px -> morphology px).
ImageFrame is the single description of that mapping for one dataset:
every step that produces or checks coordinates (polygons, the id check,
transcripts, regions) takes the same frame, so a pixel-size override or an
alignment is given once and cannot reach one step but not another.
ImageFrame
How bundle microns map onto one dataset's image pixels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
str
|
|
required |
pixel_size
|
float | None
|
microns per MORPHOLOGY pixel (the bundle's
|
required |
alignment
|
str | Path | ndarray | None
|
the shipped H&E alignment (path or matrix). Required for
|
None
|
Everything is validated on construction, so a bad value fails before any step that uses the frame touches the server.
transform
property
Morphology px -> this image's px: the INVERSE alignment for H&E, None (identity) for morphology.
create(*, bundle=None, alignment=None, pixel_size=None, image=None)
classmethod
The frame for a dataset: H&E when an alignment is given (unless
image says otherwise), the bundle's pixel size unless
pixel_size overrides it. bundle is only read when no
pixel_size is given.
with_alignment(alignment)
This frame plus an alignment it lacks (e.g. so regions drawn in H&E pixels can go on the morphology image). An alignment that differs from the frame's own is an error, never a replacement.
require_pixel_size()
The pixel size; raises if this frame can't convert microns.
microns_to_pixels(xy_um)
[K, 2] microns -> [K, 2] pixels of this image.
polygon_coordinates(vertices_um, n_vertices)
One polygon's coordinate list from its interleaved micron
vertices ([x0, y0, x1, y1, ...], padded past n_vertices).
region_transform(drawn_in)
[K, 2] coordinates drawn in drawn_in -> this image's pixels.
========== ========== ==============================
drawn_in image transform
========== ========== ==============================
he he identity
he morphology alignment M
morphology morphology identity
morphology he M^-1
microns morphology / pixel_size
microns he / pixel_size, then M^-1
========== ========== ==============================
load_alignment(alignment)
The 3x3 H&E alignment (H&E px -> morphology px) AS SHIPPED, or None.
Takes the *_he_imagealignment.csv path or the matrix itself, and
checks it is a 3x3 invertible matrix.
same_value(a, b)
The one comparison of frame values (pixel sizes, matrices): both None, or equal within 1e-9.
geojson_features(geojson)
The features of any GeoJSON shape a tool writes: a FeatureCollection, a bare array of Features (QuPath without "Export as FeatureCollection"), one Feature, or one bare geometry.
geojson_class_name(properties)
QuPath's classification.name, else a plain name.
geojson_outer_rings(geometry)
Outer ring of a Polygon, or of every polygon of a MultiPolygon, as a [K, 2] float array.
nimbusimage.xenium.embedding
The UMAP a Xenium bundle does NOT ship.
analysis.zarr.zip holds only cell_groups (clusterings) — no PCA, no
UMAP. Needs the xenium-umap extra (scikit-learn, umap-learn).
compute_umap(bundle, out_dir=None, *, components=50, n_neighbors=15, min_dist=0.3, seed=0)
float32 [N, 2] embedding, row i = cell_index i.
Gene rows of the counts -> normalize per cell (1e4) -> log1p ->
TruncatedSVD(components) -> UMAP(2D). With out_dir, writes
umap_xy.npy and pca.npy there. ~335 s for 709k cells.
nimbusimage.xenium.errors
Errors raised by the Xenium ingest.
XeniumError
Bases: Exception
A Xenium bundle, input file, or upload does not match expectations.
Raised instead of writing wrong data: a missing gene, a malformed
alignment, annotations out of cell_index order, a server that
created fewer annotations than were sent.