Skip to content

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_polygons returns it; open_cells loads or re-derives it, verified. Every per-cell step starts with cells.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

"morphology" or "he" — which image the dataset shows.

required
pixel_size float | None

microns per MORPHOLOGY pixel (the bundle's pixel_size unless deliberately overridden). None only for a frame that never converts microns (regions drawn in pixels).

required
alignment str | Path | ndarray | None

the shipped H&E alignment (path or matrix). Required for "he"; optional for morphology, where only regions drawn in H&E pixels need it.

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.