Skip to content

Loading data

From 10x output

read_10x

read_10x(data_dir: Union[str, Path], var_names: str = 'gene_symbols', make_unique: bool = True) -> tuple[csc_matrix, list[str], list[str]]

Read 10X Genomics output directory.

Mirrors R's Read10X(). Supports both v2 (genes.tsv) and v3 (features.tsv.gz) directory layouts.

Parameters:

  • data_dir (Union[str, Path]) –

    path to the 10X output directory

  • var_names (str, default: 'gene_symbols' ) –

    'gene_symbols' (default) or 'gene_ids'

  • make_unique (bool, default: True ) –

    append suffix to duplicate gene names

Returns:

  • (matrix, feature_names, cell_names)
  • matrix is (features × cells) csc_matrix.
Source code in truecell/io.py
def read_10x(
    data_dir: Union[str, Path],
    var_names: str = "gene_symbols",
    make_unique: bool = True,
) -> tuple[sp.csc_matrix, list[str], list[str]]:
    """Read 10X Genomics output directory.

    Mirrors R's Read10X(). Supports both v2 (genes.tsv) and v3
    (features.tsv.gz) directory layouts.

    Parameters
    ----------
    data_dir    : path to the 10X output directory
    var_names   : 'gene_symbols' (default) or 'gene_ids'
    make_unique : append suffix to duplicate gene names

    Returns
    -------
    (matrix, feature_names, cell_names)
    matrix is (features × cells) csc_matrix.
    """
    data_dir = Path(data_dir)

    # Detect v2 vs v3 layout
    if (data_dir / "features.tsv.gz").exists():
        matrix_file = data_dir / "matrix.mtx.gz"
        barcodes_file = data_dir / "barcodes.tsv.gz"
        features_file = data_dir / "features.tsv.gz"
    elif (data_dir / "genes.tsv").exists():
        matrix_file = data_dir / "matrix.mtx"
        barcodes_file = data_dir / "barcodes.tsv"
        features_file = data_dir / "genes.tsv"
    elif (data_dir / "features.tsv").exists():
        matrix_file = data_dir / "matrix.mtx"
        barcodes_file = data_dir / "barcodes.tsv"
        features_file = data_dir / "features.tsv"
    else:
        raise FileNotFoundError(
            f"No 10X matrix files found in {data_dir}. "
            "Expected genes.tsv/features.tsv[.gz], barcodes.tsv[.gz], matrix.mtx[.gz]."
        )

    # Read matrix
    mat = _read_mtx(matrix_file)
    mat = sp.csc_matrix(mat)

    # Read barcodes
    cell_names = _read_tsv_column(barcodes_file, col=0)

    # Read features
    gene_ids = _read_tsv_column(features_file, col=0)
    gene_symbols = _read_tsv_column(features_file, col=1)

    feature_names = gene_symbols if var_names == "gene_symbols" else gene_ids

    if make_unique:
        feature_names = _make_unique(feature_names)

    # Validate shapes
    if mat.shape[0] != len(feature_names):
        raise ValueError(
            f"Matrix rows ({mat.shape[0]}) != feature count ({len(feature_names)})."
        )
    if mat.shape[1] != len(cell_names):
        raise ValueError(
            f"Matrix cols ({mat.shape[1]}) != barcode count ({len(cell_names)})."
        )

    return mat, feature_names, cell_names

Bundled datasets

truecell.datasets stands in for R's SeuratData. Each loader downloads on first call into ~/.truecell_data/ and returns a ready Truecell object; the whole set is roughly 770 MB cached. These are the datasets the tutorials run on, which is what makes each tutorial reproducible from a clean machine.

pbmc3k

pbmc3k(data_dir: Optional[str] = None, force_download: bool = False) -> tuple[csc_matrix, list[str], list[str]]

Download (if needed) and load the PBMC 3k dataset.

Returns (counts_matrix, gene_names, cell_barcodes). matrix is (genes × cells) csc_matrix with raw counts.

Parameters:

  • data_dir (Optional[str], default: None ) –

    directory to cache the raw files (defaults to ~/.truecell_data/pbmc3k)

  • force_download (bool, default: False ) –

    re-download even if files exist

Source code in truecell/datasets.py
def pbmc3k(
    data_dir: Optional[str] = None,
    force_download: bool = False,
) -> tuple[sp.csc_matrix, list[str], list[str]]:
    """Download (if needed) and load the PBMC 3k dataset.

    Returns (counts_matrix, gene_names, cell_barcodes).
    matrix is (genes × cells) csc_matrix with raw counts.

    Parameters
    ----------
    data_dir        : directory to cache the raw files
                      (defaults to ~/.truecell_data/pbmc3k)
    force_download  : re-download even if files exist
    """
    from .io import read_10x

    base = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "pbmc3k")
    matrix_dir = base / _PBMC3K_DIR

    if force_download or not (matrix_dir / "matrix.mtx").exists():
        _download_10x(_PBMC3K_URLS, base, label="PBMC3k", size_mb=24)

    mat, genes, cells = read_10x(matrix_dir, var_names="gene_symbols")
    return mat, genes, cells

pbmc8k

pbmc8k(data_dir: Optional[str] = None, force_download: bool = False) -> tuple[csc_matrix, list[str], list[str]]

Download (if needed) and load the 10x Genomics PBMC 8k dataset.

~8,400 peripheral blood mononuclear cells (GRCh38, v2 chemistry). Larger than pbmc3k and used by the advanced subclustering tutorial.

Returns (counts_matrix, gene_names, cell_barcodes); matrix is (genes x cells) csc_matrix with raw counts.

Parameters:

  • data_dir (Optional[str], default: None ) –

    directory to cache the raw files (defaults to ~/.truecell_data/pbmc8k)

  • force_download (bool, default: False ) –

    re-download even if files exist

Source code in truecell/datasets.py
def pbmc8k(
    data_dir: Optional[str] = None,
    force_download: bool = False,
) -> tuple[sp.csc_matrix, list[str], list[str]]:
    """Download (if needed) and load the 10x Genomics PBMC 8k dataset.

    ~8,400 peripheral blood mononuclear cells (GRCh38, v2 chemistry). Larger
    than :func:`pbmc3k` and used by the advanced subclustering tutorial.

    Returns (counts_matrix, gene_names, cell_barcodes); matrix is
    (genes x cells) csc_matrix with raw counts.

    Parameters
    ----------
    data_dir        : directory to cache the raw files
                      (defaults to ~/.truecell_data/pbmc8k)
    force_download  : re-download even if files exist
    """
    from .io import read_10x

    base = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "pbmc8k")
    matrix_dir = base / _PBMC8K_DIR

    if force_download or not (matrix_dir / "matrix.mtx").exists():
        _download_10x(_PBMC8K_URLS, base, label="PBMC8k", size_mb=38)

    mat, genes, cells = read_10x(matrix_dir, var_names="gene_symbols")
    return mat, genes, cells

cbmc_citeseq

cbmc_citeseq(data_dir: Optional[str] = None, force_download: bool = False, species_prefix: str = _CBMC_SPECIES_PREFIX)

Download (if needed) and load the CBMC CITE-seq dataset (GSE100866).

~8,600 cord-blood mononuclear cells profiled for both RNA and 13 surface proteins (ADT). The RNA matrix mixes human and mouse spike-in genes; this loader keeps the human genes and strips the HUMAN_ prefix (mirroring Seurat's CollapseSpeciesExpressionMatrix). RNA and ADT are aligned to their shared cell barcodes.

Returns:

  • ((rna_counts, rna_genes, adt_counts, adt_proteins, cell_names), where)
  • ``rna_counts`` is a (genes x cells) ``csc_matrix`` and ``adt_counts`` is a
  • (proteins x cells) ``csc_matrix`` in the same cell order.
Source code in truecell/datasets.py
def cbmc_citeseq(
    data_dir: Optional[str] = None,
    force_download: bool = False,
    species_prefix: str = _CBMC_SPECIES_PREFIX,
):
    """Download (if needed) and load the CBMC CITE-seq dataset (GSE100866).

    ~8,600 cord-blood mononuclear cells profiled for both RNA and 13 surface
    proteins (ADT). The RNA matrix mixes human and mouse spike-in genes; this
    loader keeps the human genes and strips the ``HUMAN_`` prefix (mirroring
    Seurat's CollapseSpeciesExpressionMatrix). RNA and ADT are aligned to their
    shared cell barcodes.

    Returns
    -------
    (rna_counts, rna_genes, adt_counts, adt_proteins, cell_names), where
    ``rna_counts`` is a (genes x cells) ``csc_matrix`` and ``adt_counts`` is a
    (proteins x cells) ``csc_matrix`` in the same cell order.
    """
    import pandas as pd

    from .io import _make_unique

    base = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "cbmc")
    base.mkdir(parents=True, exist_ok=True)

    rna_path = base / _CBMC_RNA
    adt_path = base / _CBMC_ADT
    for fname, path in ((_CBMC_RNA, rna_path), (_CBMC_ADT, adt_path)):
        if force_download or not path.exists():
            _download_file(_CBMC_BASE + fname, path, label=fname)

    # ADT is tiny — read directly (proteins x cells).
    adt_df = pd.read_csv(adt_path, index_col=0)
    adt_cells = list(adt_df.columns)

    # RNA is larger — read in row-chunks, keeping only human genes.
    rna_blocks: list[sp.csr_matrix] = []
    rna_genes: list[str] = []
    rna_cells: Optional[list[str]] = None
    for chunk in pd.read_csv(rna_path, index_col=0, chunksize=4000):
        if rna_cells is None:
            rna_cells = list(chunk.columns)
        mask = np.asarray(chunk.index.str.startswith(species_prefix))
        sub = chunk[mask]
        if len(sub):
            rna_genes.extend(g[len(species_prefix):] for g in sub.index)
            rna_blocks.append(sp.csr_matrix(sub.values.astype(np.float32)))
    if rna_cells is None or not rna_blocks:
        # No rows at all, or none surviving the species filter — a wrong
        # `species_prefix` used to surface as sp.vstack's "blocks must be 2-D".
        raise ValueError(
            f"{rna_path} yielded no rows starting with {species_prefix!r}"
        )
    rna_mat = sp.vstack(rna_blocks, format="csc")
    rna_genes = _make_unique(rna_genes)

    # Align to shared barcodes, ordered by the RNA matrix.
    adt_set = set(adt_cells)
    rna_pos = {c: i for i, c in enumerate(rna_cells)}
    common = [c for c in rna_cells if c in adt_set]
    rna_cols = [rna_pos[c] for c in common]
    rna_mat = rna_mat[:, rna_cols].tocsc()
    adt_mat = sp.csc_matrix(adt_df[common].values.astype(np.float32))

    return rna_mat, rna_genes, adt_mat, list(adt_df.index), common

pbmc_hashing

pbmc_hashing(data_dir: Optional[str] = None, force_download: bool = False)

Download (if needed) and load the PBMC Cell-Hashing dataset (GSE108313).

The 8-HTO experiment from Stoeckius et al. (2018), as used by Seurat's hashing vignette. Returns raw RNA counts aligned to the HTO counts on their shared cell barcodes; the three non-hashtag QC rows (bad_struct, no_match, total_reads) are dropped from the HTO matrix, leaving the 8 hashtags (BatchABatchH).

The RNA reference is a combined human+mouse genome (both MT- and mt- genes are present), which is deliberate: cross-species doublets validate the HTO doublet calls.

Returns:

  • ((rna_counts, rna_genes, hto_counts, hto_names, cell_names), where)
  • ``rna_counts`` is a (genes x cells) ``csc_matrix`` of raw counts and
  • ``hto_counts`` is an (8 x cells) ``csc_matrix`` in the same cell order.
Source code in truecell/datasets.py
def pbmc_hashing(
    data_dir: Optional[str] = None,
    force_download: bool = False,
):
    """Download (if needed) and load the PBMC Cell-Hashing dataset (GSE108313).

    The 8-HTO experiment from Stoeckius et al. (2018), as used by Seurat's
    hashing vignette. Returns raw RNA counts aligned to the HTO counts on their
    shared cell barcodes; the three non-hashtag QC rows (``bad_struct``,
    ``no_match``, ``total_reads``) are dropped from the HTO matrix, leaving the
    8 hashtags (``BatchA``–``BatchH``).

    The RNA reference is a combined human+mouse genome (both ``MT-`` and ``mt-``
    genes are present), which is deliberate: cross-species doublets validate the
    HTO doublet calls.

    Returns
    -------
    (rna_counts, rna_genes, hto_counts, hto_names, cell_names), where
    ``rna_counts`` is a (genes x cells) ``csc_matrix`` of raw counts and
    ``hto_counts`` is an (8 x cells) ``csc_matrix`` in the same cell order.
    """
    from .io import _make_unique

    base = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "pbmc_hashing")
    base.mkdir(parents=True, exist_ok=True)

    rna_path = base / "GSM2895282_Hashtag-RNA.umi.txt.gz"
    hto_path = base / "GSM2895283_Hashtag-HTO-count.csv.gz"
    if force_download or not rna_path.exists():
        _download_file(_HASHING_RNA_URL, rna_path, label="Cell Hashing RNA (~32 MB)")
    if force_download or not hto_path.exists():
        _download_file(_HASHING_HTO_URL, hto_path, label="Cell Hashing HTO (~1 MB)")

    rna_mat, rna_genes, rna_cells = _read_table_cached(rna_path, sep="\t")
    hto_mat, hto_names, hto_cells = _read_table_cached(hto_path, sep=",")

    # Drop the QC-tally rows, keeping only the hashtags.
    keep = [i for i, n in enumerate(hto_names) if n not in _HASHING_HTO_SKIP]
    hto_mat = hto_mat[keep, :].tocsr()
    hto_names = [hto_names[i] for i in keep]

    rna_genes = _make_unique(rna_genes)
    rna_mat, hto_mat, cells = _align_on_cells(rna_mat, rna_cells, hto_mat, hto_cells)
    return rna_mat, rna_genes, hto_mat, hto_names, cells

ifnb

ifnb(data_dir: Optional[str] = None)

Load the IFNB-stimulated PBMC dataset (Kang et al. 2018), via SeuratData.

~14,000 human PBMCs, half stimulated with interferon-beta and half control — the standard benchmark for batch integration (correcting the stim/ctrl shift while preserving cell type). Curated as SeuratData's ifnb, so it is loaded through the R export bridge (see _load_seuratdata_export).

Returns (counts, genes, cells, meta); meta carries stim (CTRL/STIM — the batch) and seurat_annotations (the cell types).

Source code in truecell/datasets.py
def ifnb(data_dir: Optional[str] = None):
    """Load the IFNB-stimulated PBMC dataset (Kang et al. 2018), via SeuratData.

    ~14,000 human PBMCs, half stimulated with interferon-beta and half control —
    the standard benchmark for batch integration (correcting the stim/ctrl shift
    while preserving cell type). Curated as SeuratData's ``ifnb``, so it is loaded
    through the R export bridge (see :func:`_load_seuratdata_export`).

    Returns ``(counts, genes, cells, meta)``; ``meta`` carries ``stim``
    (CTRL/STIM — the batch) and ``seurat_annotations`` (the cell types).
    """
    return _load_seuratdata_export("ifnb", data_dir)

panc8

panc8(data_dir: Optional[str] = None)

Load the human pancreatic-islet dataset panc8 (8 techs), via SeuratData.

~14,900 cells profiled across five/eight technologies (CEL-seq, CEL-seq2, Fluidigm C1, SMART-seq2, inDrop) — a cross-technology integration and reference-mapping benchmark. Loaded through the R export bridge.

Returns (counts, genes, cells, meta); meta carries tech (the batch / technology) and celltype (the reference annotation).

Source code in truecell/datasets.py
def panc8(data_dir: Optional[str] = None):
    """Load the human pancreatic-islet dataset ``panc8`` (8 techs), via SeuratData.

    ~14,900 cells profiled across five/eight technologies (CEL-seq, CEL-seq2,
    Fluidigm C1, SMART-seq2, inDrop) — a cross-technology integration and
    reference-mapping benchmark. Loaded through the R export bridge.

    Returns ``(counts, genes, cells, meta)``; ``meta`` carries ``tech`` (the
    batch / technology) and ``celltype`` (the reference annotation).
    """
    return _load_seuratdata_export("panc8", data_dir)

thp1_eccite

thp1_eccite(data_dir: Optional[str] = None, force_download: bool = False)

Download (if needed) and load the THP-1 ECCITE-seq dataset (GSE153056).

The pooled-CRISPR screen from Papalexi et al. (2021) used by Seurat's Mixscape vignette. Returns RNA + ADT counts and the per-cell metadata (guide assignment, targeted gene, replicate, cell-cycle phase), all aligned to their shared barcodes. The gene / guide_ID / NT columns of the metadata are the perturbation labels run_mixscape needs; NT marks the non-targeting controls.

Returns:

  • ((rna_counts, rna_genes, adt_counts, adt_names, meta, cell_names), where)
  • ``rna_counts`` is a (genes x cells) ``csc_matrix`` of raw counts,
  • ``adt_counts`` is a (proteins x cells) ``csc_matrix`` in the same cell
  • order, and ``meta`` is a ``pandas.DataFrame`` indexed by cell barcode
  • (``guide_ID``, ``gene``, ``NT``, ``crispr``, ``replicate``, ``Phase``,
  • ``S.Score``, ``G2M.Score``, ...).
Source code in truecell/datasets.py
def thp1_eccite(
    data_dir: Optional[str] = None,
    force_download: bool = False,
):
    """Download (if needed) and load the THP-1 ECCITE-seq dataset (GSE153056).

    The pooled-CRISPR screen from Papalexi et al. (2021) used by Seurat's
    Mixscape vignette. Returns RNA + ADT counts and the per-cell metadata
    (guide assignment, targeted gene, replicate, cell-cycle phase), all aligned
    to their shared barcodes. The ``gene`` / ``guide_ID`` / ``NT`` columns of the
    metadata are the perturbation labels ``run_mixscape`` needs; ``NT`` marks the
    non-targeting controls.

    Returns
    -------
    (rna_counts, rna_genes, adt_counts, adt_names, meta, cell_names), where
    ``rna_counts`` is a (genes x cells) ``csc_matrix`` of raw counts,
    ``adt_counts`` is a (proteins x cells) ``csc_matrix`` in the same cell
    order, and ``meta`` is a ``pandas.DataFrame`` indexed by cell barcode
    (``guide_ID``, ``gene``, ``NT``, ``crispr``, ``replicate``, ``Phase``,
    ``S.Score``, ``G2M.Score``, ...).
    """
    import pandas as pd

    from .io import _make_unique

    base = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "thp1_eccite")
    base.mkdir(parents=True, exist_ok=True)

    rna_path = base / "GSM4633614_ECCITE_cDNA_counts.tsv.gz"
    adt_path = base / "GSM4633615_ECCITE_ADT_counts.tsv.gz"
    meta_path = base / "GSE153056_ECCITE_metadata.tsv.gz"
    if force_download or not rna_path.exists():
        _download_file(_ECCITE_RNA_URL, rna_path, label="ECCITE RNA (~64 MB)")
    if force_download or not adt_path.exists():
        _download_file(_ECCITE_ADT_URL, adt_path, label="ECCITE ADT (~1 MB)")
    if force_download or not meta_path.exists():
        _download_file(_ECCITE_META_URL, meta_path, label="ECCITE metadata (~1 MB)")

    rna_mat, rna_genes, rna_cells = _read_table_cached(rna_path, sep="\t")
    adt_mat, adt_names, adt_cells = _read_table_cached(adt_path, sep="\t")
    meta = pd.read_csv(meta_path, sep="\t", index_col=0)

    rna_genes = _make_unique(rna_genes)
    rna_mat, adt_mat, cells = _align_on_cells(rna_mat, rna_cells, adt_mat, adt_cells)
    # Restrict to cells that also have metadata, preserving the RNA order.
    meta_set = set(meta.index)
    keep = [i for i, c in enumerate(cells) if c in meta_set]
    cells = [cells[i] for i in keep]
    rna_mat = rna_mat[:, keep].tocsc()
    adt_mat = adt_mat[:, keep].tocsc()
    meta = meta.loc[cells]
    return rna_mat, rna_genes, adt_mat, adt_names, meta, cells

xenium_mouse_brain

xenium_mouse_brain(data_dir: Optional[str] = None, force_download: bool = False) -> Path

Download (if needed) the 10x Xenium mouse-brain coronal subset.

Fetches only the analysis components (cell_feature_matrix/ + cells), ~20 MB, into data_dir (default ~/.truecell_data/xenium_mouse_brain) and returns the folder path — ready to pass to truecell.load_xenium.

This is the public section featured in Seurat's Xenium spatial vignette, so the same analysis runs in R (LoadXenium) and Python (load_xenium).

Source code in truecell/datasets.py
def xenium_mouse_brain(
    data_dir: Optional[str] = None,
    force_download: bool = False,
) -> Path:
    """Download (if needed) the 10x Xenium mouse-brain coronal subset.

    Fetches only the analysis components (``cell_feature_matrix/`` + ``cells``),
    ~20 MB, into ``data_dir`` (default ``~/.truecell_data/xenium_mouse_brain``) and
    returns the folder path — ready to pass to :func:`truecell.load_xenium`.

    This is the public section featured in Seurat's Xenium spatial vignette, so
    the same analysis runs in R (``LoadXenium``) and Python (``load_xenium``).
    """
    root = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "xenium_mouse_brain")
    root.mkdir(parents=True, exist_ok=True)

    mtx_dir = root / "cell_feature_matrix"
    need = force_download or not (mtx_dir / "matrix.mtx.gz").exists()
    if need:
        tar_dest = root / "cell_feature_matrix.tar.gz"
        _download_file(_XENIUM_MB_BASE + _XENIUM_MB_FILES["cell_feature_matrix.tar.gz"],
                       tar_dest, label="Xenium mouse brain matrix (~11 MB)")
        with tarfile.open(tar_dest, "r:gz") as tf:
            tf.extractall(root)
        os.unlink(tar_dest)

    cells_dest = root / "cells.csv.gz"
    if force_download or not cells_dest.exists():
        _download_file(_XENIUM_MB_BASE + _XENIUM_MB_FILES["cells.csv.gz"],
                       cells_dest, label="Xenium mouse brain cells (~2 MB)")
    return root

visium_mouse_brain

visium_mouse_brain(data_dir: Optional[str] = None, force_download: bool = False) -> Path

Download (if needed) the 10x Visium mouse-brain sagittal-anterior section.

Fetches the Space Ranger bundle (~64 MB) into data_dir (default ~/.truecell_data/visium_mouse_brain) and returns the folder path — ready to pass to truecell.load_visium, and to R's Read10X_Image / Load10X_Spatial, so the same slide runs in both languages.

Source code in truecell/datasets.py
def visium_mouse_brain(
    data_dir: Optional[str] = None,
    force_download: bool = False,
) -> Path:
    """Download (if needed) the 10x Visium mouse-brain sagittal-anterior section.

    Fetches the Space Ranger bundle (~64 MB) into ``data_dir`` (default
    ``~/.truecell_data/visium_mouse_brain``) and returns the folder path — ready to
    pass to :func:`truecell.load_visium`, and to R's ``Read10X_Image`` /
    ``Load10X_Spatial``, so the same slide runs in both languages.
    """
    root = (Path(data_dir) if data_dir is not None
            else Path.home() / ".truecell_data" / "visium_mouse_brain")
    root.mkdir(parents=True, exist_ok=True)

    # Each component is one tarball that unpacks to a directory of the same name.
    for name, (suffix, label) in _VISIUM_MB_FILES.items():
        if not force_download and (root / name).is_dir():
            continue
        tar_dest = root / f"{name}.tar.gz"
        _download_file(_VISIUM_MB_BASE + suffix, tar_dest,
                       label=f"Visium mouse brain {label}")
        with tarfile.open(tar_dest, "r:gz") as tf:
            tf.extractall(root)
        os.unlink(tar_dest)
    return root

AnnData interoperability

as_anndata

as_anndata(seurat, assay: Optional[str] = None)

Convert a Seurat object to anndata.AnnData.

Mapping

active_assay counts/data layer → adata.X (+ adata.layers for extras) meta_data → adata.obs assay.meta_features / meta_data → adata.var reductions["pca"].embeddings → adata.obsm["X_pca"] reductions["pca"].loadings → adata.varm["PCs"] graphs → adata.obsp misc → adata.uns

Source code in truecell/compat/anndata.py
def as_anndata(seurat, assay: Optional[str] = None):
    """Convert a Seurat object to anndata.AnnData.

    Mapping
    -------
    active_assay counts/data layer → adata.X  (+ adata.layers for extras)
    meta_data                       → adata.obs
    assay.meta_features / meta_data → adata.var
    reductions["pca"].embeddings    → adata.obsm["X_pca"]
    reductions["pca"].loadings      → adata.varm["PCs"]
    graphs                          → adata.obsp
    misc                            → adata.uns
    """
    try:
        import anndata
    except ImportError:
        raise ImportError(
            "anndata is required. Install with: pip install 'seurat-object[anndata]'"
        )

    from ..assay5 import StdAssay
    from ..truecell import Truecell

    if not isinstance(seurat, Truecell):
        raise TypeError(f"Expected Truecell, got {type(seurat).__name__}.")

    assay_name = assay or seurat.active_assay
    assay_obj = seurat.assays.get(assay_name)
    if assay_obj is None:
        raise KeyError(f"Assay '{assay_name}' not found.")

    cells = seurat.cell_names()

    # ---- X ----
    if isinstance(assay_obj, StdAssay):
        default_layer = assay_obj.default_layer
        X = assay_obj.layers.get(default_layer) if default_layer else None
        extra_layers = {
            k: v for k, v in assay_obj.layers.items() if k != default_layer
        }
        var_df = assay_obj.meta_data.copy() if assay_obj.meta_data is not None else pd.DataFrame()
        feature_names = assay_obj._all_feature_names
    else:
        from .._sparse import is_matrix_empty
        X = assay_obj.data if not is_matrix_empty(assay_obj.data) else assay_obj.counts
        extra_layers = {}
        if not is_matrix_empty(assay_obj.counts):
            extra_layers["counts"] = assay_obj.counts
        if not is_matrix_empty(assay_obj.scale_data):
            extra_layers["scale_data"] = assay_obj.scale_data
        var_df = assay_obj.meta_features.copy()
        feature_names = assay_obj._feature_names

    if X is None:
        X = sp.csc_matrix((len(feature_names), len(cells)))

    # anndata wants obs × var (cells × features), so transpose
    X_t = X.T if sp.issparse(X) else X.T

    # ---- obs ----
    obs = seurat.meta_data.copy()
    obs["ident"] = list(seurat.idents)

    # ---- var ----
    var = var_df.copy()
    var.index = feature_names

    # ---- layers ----
    layers_out = {}
    for layer_name, mat in extra_layers.items():
        layers_out[layer_name] = mat.T if sp.issparse(mat) else mat.T

    # ---- obsm ----
    obsm = {}
    for red_name, dr in seurat.reductions.items():
        key = f"X_{red_name.lower()}"
        obsm[key] = dr.cell_embeddings

    # ---- varm ----
    varm = {}
    for red_name, dr in seurat.reductions.items():
        if dr.feature_loadings.shape[0] == len(feature_names):
            varm[red_name.upper() + "s"] = dr.feature_loadings

    # ---- obsp ----
    obsp = {}
    for g_name, g in seurat.graphs.items():
        obsp[g_name] = g._matrix

    # ---- uns ----
    uns = dict(seurat.misc)
    uns["project_name"] = seurat.project_name
    uns["active_assay"] = assay_name

    return anndata.AnnData(
        X=X_t,
        obs=obs,
        var=var,
        layers=layers_out,
        obsm=obsm,
        varm=varm,
        obsp=obsp,
        uns=uns,
    )

from_anndata

from_anndata(adata, assay: str = 'RNA', spatial_key: str = 'spatial', fov_key: str = 'fov') -> 'Truecell'

Convert an anndata.AnnData to a Seurat object.

Mapping

adata.X → Assay5 'counts' layer (transposed → features × cells) adata.layers → additional Assay5 layers adata.obs → seurat.meta_data adata.var → assay.meta_data adata.obsm["X_pca"] → seurat.reductions["pca"].cell_embeddings adata.obsm[spatial_key] → seurat.images (Centroids/FOV, split by obs[fov_key]) adata.varm["PCs"] → seurat.reductions["pca"].feature_loadings adata.obsp["connectivities"] → seurat.graphs adata.uns → seurat.misc

spatial_key (default "spatial") is treated as physical coordinates and reconstructed into seurat.images — NOT as a dimensional reduction — so get_tissue_coordinates and the spatial-analysis functions work. If obs[fov_key] exists it splits the cells into one image per FOV.

Source code in truecell/compat/anndata.py
def from_anndata(
    adata,
    assay: str = "RNA",
    spatial_key: str = "spatial",
    fov_key: str = "fov",
) -> "Truecell":
    """Convert an anndata.AnnData to a Seurat object.

    Mapping
    -------
    adata.X                   → Assay5 'counts' layer  (transposed → features × cells)
    adata.layers              → additional Assay5 layers
    adata.obs                 → seurat.meta_data
    adata.var                 → assay.meta_data
    adata.obsm["X_pca"]       → seurat.reductions["pca"].cell_embeddings
    adata.obsm[spatial_key]   → seurat.images  (Centroids/FOV, split by obs[fov_key])
    adata.varm["PCs"]         → seurat.reductions["pca"].feature_loadings
    adata.obsp["connectivities"] → seurat.graphs
    adata.uns                 → seurat.misc

    ``spatial_key`` (default ``"spatial"``) is treated as physical coordinates
    and reconstructed into ``seurat.images`` — NOT as a dimensional reduction —
    so ``get_tissue_coordinates`` and the spatial-analysis functions work. If
    ``obs[fov_key]`` exists it splits the cells into one image per FOV.
    """
    try:
        import anndata  # noqa: F401  — probed for availability, not used here
    except ImportError:
        raise ImportError(
            "anndata is required. Install with: pip install 'seurat-object[anndata]'"
        )

    from ..assay5 import Assay5
    from ..dimreduc import DimReduc
    from ..graph import Graph
    from ..truecell import Truecell, _VERSION

    cells = list(adata.obs_names)
    features = list(adata.var_names)

    # ---- Build Assay5 layers ----
    X = adata.X
    if sp.issparse(X):
        X_t = X.T.tocsc()
    else:
        X_t = np.asarray(X).T

    layers: dict = {"counts": X_t}
    for layer_name, mat in adata.layers.items():
        if sp.issparse(mat):
            layers[layer_name] = mat.T.tocsc()
        else:
            layers[layer_name] = np.asarray(mat).T

    meta_data_var = adata.var.copy() if adata.var is not None else pd.DataFrame(index=features)
    assay_obj = Assay5(
        layers=layers,
        feature_names=features,
        cell_names=cells,
        meta_data=meta_data_var,
        key=f"{assay.lower()}_",
    )

    # ---- Metadata ----
    meta_data = adata.obs.copy() if adata.obs is not None else pd.DataFrame(index=cells)

    # ---- Spatial images (obsm[spatial_key] → Centroids/FOV) ----
    images: dict = {}
    if spatial_key in adata.obsm:
        from ..spatial.fov import create_fovs
        xy = np.asarray(adata.obsm[spatial_key])[:, :2]
        coords = pd.DataFrame({"x": xy[:, 0], "y": xy[:, 1], "cell": cells})
        fov_labels = (adata.obs[fov_key].astype(str).to_numpy()
                      if fov_key in adata.obs.columns else None)
        images = create_fovs(coords, fov=fov_labels, assay=assay,
                             default_name=assay.lower())

    # ---- Reductions ----
    reductions: dict = {}
    for obsm_key, emb in adata.obsm.items():
        if obsm_key == spatial_key:
            continue                         # handled as images, not a reduction
        if obsm_key.startswith("X_"):
            red_name = obsm_key[2:]
        else:
            red_name = obsm_key

        varm_key = red_name.upper() + "s"
        loadings = adata.varm.get(varm_key) if adata.varm is not None else None

        dr = DimReduc(
            cell_embeddings=np.asarray(emb),
            cell_names=cells,
            feature_loadings=np.asarray(loadings) if loadings is not None else None,
            feature_names=features if loadings is not None else None,
            assay_used=assay,
            key=f"{red_name.upper()}_",
        )
        reductions[red_name] = dr

    # ---- Graphs ----
    graphs: dict = {}
    if adata.obsp is not None:
        for obsp_key, mat in adata.obsp.items():
            g = Graph(matrix=mat if sp.issparse(mat) else sp.csc_matrix(mat), cell_names=cells)
            graphs[obsp_key] = g

    # ---- misc ----
    misc = dict(adata.uns) if adata.uns is not None else {}
    project_name = misc.pop("project_name", "SeuratProject")

    return Truecell(
        assays={assay: assay_obj},
        meta_data=meta_data,
        active_assay=assay,
        graphs=graphs,
        reductions=reductions,
        images=images,
        project_name=project_name,
        misc=misc,
        version=_VERSION,
    )