Skip to content

Align Module API Reference

Alignment and registration of spatial single-cell data.

Covers three related registration problems: (1) stitching serial 3D tissue slices into a shared coordinate frame, (2) registering data onto a reference (e.g. a common coordinate framework/atlas such as the Allen Institute mouse brain, or a particular dataset within a cohort), and (3) registering across modalities (e.g. Xenium to H&E). All three align in physical (spatial) coordinates — image/tissue pixel or micron space. Batch correction / integration in expression or embedding space (e.g. Harmony, scVI, Seurat anchors) is a different problem and out of scope here, though a shared framework for evaluating alignment quality could plausibly extend to that space later.

Status: initial sketch. Implemented: a three-step serial-slice pipeline, each step an independently reusable artifact. (1) :func:calc_landmarks computes landmarks from shared cluster labels (accepting a list of AnnData or one combined AnnData, no manual per-slice loop needed), or a manually-placed landmark table in the same shape (a plain DataFrame, not a GeoDataFrame of shapely geometry, so it stays trivially disk-portable; e.g. from :class:~celldega.viz.Landmark, a point-drawing widget pairing with Landscape) can be used instead, or concatenated alongside it for a semi-manual mix. (2) :func:calc_alignment_transform fits a rigid Procrustes (:func:fit_transform_procrustes, always without scaling — see :mod:celldega.align.serial_slices) or non-rigid thin-plate-spline (:func:fit_transform_tps) transform per slice from those landmarks — chain-walking outward from a reference slice against a window of already-aligned neighbors (alignment_window, never a single distant reference), optionally weighting landmarks by cell count (weight_by_adjacent_counts) and recording per-landmark leave-one-out residuals (:func:leave_one_out_residuals) — and returns a :class:~celldega.align.serial_slices.SerialAlignmentTransform: a first-class, reusable object (not a byproduct that only lives inside one alignment call) that can be applied to other point data tied to the same slices (segmentation-polygon vertices, transcript coordinates, eventually raster sampling grids) via .apply_to_points(), and persisted with .save()/.load() (a plain directory of .npz/.parquet/.json files, no pickle required, though it's picklable too), and visually sanity-checked with :func:plot_alignment (also available as transform.plot()): a 2D before/after scatter of the fitted landmarks, so a bad fit (or a mislabeled landmark) is visible at a glance rather than only showing up downstream. (3) :func:align_serial_slices applies a given transform to a specific set of AnnData, aligning obsm["spatial"] and assigning a Z coordinate; landmarks_initial/landmarks_aligned/fit parameters are also recorded in the output's uns["align_serial_slices"] for at-a-glance provenance. Planned: a reference/atlas-registration method sharing the same landmark-based core (star topology onto one fixed reference, vs. :func:align_serial_slices's neighbor-chain) and image-based modality-to-modality registration (deferred — unlike the two centroid-based methods, this operates on raw image data, not single-cell coordinates).

SerialAlignmentTransform dataclass

A fitted, reusable serial-slice alignment.

Returned by :func:calc_alignment_transform; consumed by :func:align_serial_slices. Holds one :class:~celldega.align._transform.Transform per slice (the reference slice's is an identity transform) and provenance from the fit — no Z information, since Z assignment doesn't affect the spatial fit at all and is decided when applying the transform (see :func:align_serial_slices's z_space/z_coord), not when fitting it. Persist it with :meth:save/:meth:load — everything here is plain data or a picklable-but-not-pickled RBFInterpolator, so it also survives a plain :mod:pickle round-trip if that's more convenient.

apply_to_points(slice_id, points)

Apply the fitted transform for slice_id to an (n, 2) array of points.

The general reuse primitive: works identically for cell coordinates, segmentation-polygon vertices, transcript coordinates, or any other point data tied to that slice.

load(path) classmethod

Load a transform previously saved with :meth:save.

plot(**kwargs)

2D before/after scatter of this alignment — a quick visual check of fit quality. Pass adatas= (the slices this was fit from) to overlay the actual cell centroids, which is what really shows whether the tissue aligns; see :func:~celldega.align.plot.plot_alignment for all accepted options.

save(path)

Save this transform to a directory of plain files — no pickle.

Layout: metadata.json (fit parameters, slice ids), transform_log.json (per-slice diagnostics), landmarks_initial .parquet/landmarks_aligned.parquet, and one transforms/<slice id>.npz per slice (see :func:~celldega.align._transform.save_transform).

align_serial_slices(adatas, transform, z_space=1.0, z_coord=None, key_added='Z', cell_name_prefix=False)

Apply a fitted :class:SerialAlignmentTransform to a set of AnnData.

Z assignment lives here, not in :func:calc_alignment_transform — it doesn't affect the spatial fit at all, so the same fitted transform can be applied with different Z choices without refitting anything.

Parameters:

Name Type Description Default
adatas AnnData | list[AnnData]

Either a list of per-slice AnnData (list order must match the slice order transform was fit with), or a single AnnData combining all slices, split by the obs column named transform.slice_attr. Only obsm["spatial"] is used — this function has no knowledge of cell metadata.

required
transform SerialAlignmentTransform

A :class:SerialAlignmentTransform from :func:calc_alignment_transform (or reloaded via :meth:SerialAlignmentTransform.load).

required
z_space float

Uniform distance between consecutive slices, applied outward from transform.reference (so that slice is Z = 0). Ignored if z_coord is given.

1.0
z_coord list[float] | None

Explicit absolute Z value for each slice (length n_slices, matching slice order) — use this when slices have known, unevenly spaced, or non-reference-relative Z positions (e.g. from instrument metadata). Overrides z_space entirely when given.

None
key_added str

Name of the new per-cell obs column holding the assigned Z position.

'Z'
cell_name_prefix bool

If True, prefix each slice's obs_names with its slice id (f"{slice_id}_{name}") before concatenating, so cells stay uniquely named even when two slices reuse the same per-slice barcode convention. Matches :class:~celldega.viz.widget.Landscape's cell_name_prefix convention (a dataset/slice id, then the original cell name, split at the first _), so the same aligned AnnData can be visualized there with cell_name_prefix=True. Default False for backward compatibility — a uniqueness warning fires either way if names collide.

False

Returns:

Type Description
AnnData

A new AnnData concatenating all slices, with obsm["spatial"]

AnnData

x/y columns replaced by the aligned coordinates, a new

AnnData

obs[key_added] Z column, and transform's fit parameters and

AnnData

landmark provenance recorded in uns["align_serial_slices"]

AnnData

(plain, h5ad-safe data — the live transform object itself is

AnnData

not stored there; keep or persist it separately, see

AnnData

meth:SerialAlignmentTransform.save).

Raises:

Type Description
ValueError

If adatas resolves to a different set or order of slices than transform was fit with, if a slice is missing obsm["spatial"], or if z_coord is given with the wrong length.

calc_alignment_transform(landmarks, slice_attr=None, reference=0, min_shared_landmarks=3, alignment_window=1, method='procrustes', allow_reflection=False, smoothing=0.0, degree=1, area_regularization=0.0, shape_regularization=0.0, weight_by_adjacent_counts=True, manual_landmark_weight='equal', compute_residuals=True)

Fit a serial-slice alignment transform from corresponding landmarks.

Each slice is registered onto a window of its already-aligned neighbors by fitting a transform between corresponding landmarks. Alignment proceeds as a chain outward from reference in both directions along the slice order, so deformation is modeled between nearby physical neighbors rather than against a single distant reference. This function only touches landmarks — no cell data — so the returned transform can be fit once and reused (see :func:align_serial_slices and :meth:SerialAlignmentTransform.apply_to_points). Z assignment isn't part of this fit — it doesn't affect the spatial transform at all — see :func:align_serial_slices's z_space/z_coord instead.

Parameters:

Name Type Description Default
landmarks DataFrame

A plain DataFrame of landmarks to fit against, with columns slice_attr (values defining slice order — see below), label (matches a landmark across slices; unique per slice), x/y, and optionally count (omit or leave NaN for a landmark with no natural cell count, e.g. a manually-placed one). Build this with :func:~celldega.align.landmarks.calc_landmarks, a manually-placed landmark table in the same shape, or both concatenated together for a semi-manual mix.

required
slice_attr str | None

The column in landmarks identifying each row's slice (default "slice"). Slice order is that column's categories if it's an ordered categorical, else sorted unique values.

None
reference int

Index (into the slice order) of the slice whose transform is the identity; other slices are aligned outward from it.

0
min_shared_landmarks int

Minimum number of landmark labels a slice and its neighbor window must share to fit a transform between them.

3
alignment_window int

Number of already-aligned neighboring slices (in the same chain direction) to register each new slice against, instead of only the single immediately-previous one. A landmark label's target position is averaged across whichever of those neighbors have it. Stays a local, neighbor-window operation — never reaches back to a single distant reference — while reducing sensitivity to any one neighbor's noise. 1 (default) reproduces the original single-neighbor chain exactly.

1
method str

"procrustes" (default) for a rigid rotation + translation fit (:func:~celldega.align._transform.fit_transform_procrustes, always with scaling disabled — see module docstring), or "tps" for a non-rigid thin-plate-spline warp (:func:~celldega.align._transform.fit_transform_tps) for deformation a single global transform can't capture.

'procrustes'
allow_reflection bool

method="procrustes" only. If False (default), disallow mirrored fits, since flipping a tissue section is not physically valid.

False
smoothing float

method="tps" only. Bending-energy penalty passed to the thin-plate-spline fit; 0 (default) interpolates landmarks exactly (which overfits noisy centroid landmarks — the warp contorts the tissue to hit each one), and raising it relaxes toward a stiffer, more affine warp that preserves each slice's shape. The fit normalizes the domain first, so this is scale-free (comparable across datasets): useful values are roughly 0 to ~0.1 to 1 (light local warp) up to ~10+ (nearly rigid). See :func:~celldega.align._transform.fit_transform_tps.

0.0
degree int

method="tps" only. Degree of the polynomial term added to the spline (see :func:~celldega.align._transform.fit_transform_tps). 1 (default) includes a full affine fallback (translation, rotation, and scale) away from the landmarks — the scale component is exactly what lets area expand/shrink globally. 0 drops it for a constant-offset fallback instead, which tends to change area less overall, though TPS has no parameter that exactly constrains area/Jacobian the way method="procrustes" (always rigid, scale disabled — see above) does; this only shifts the fit's default behavior, it doesn't hard-enforce anything.

1
area_regularization float

method="tps" only. Penalty in [0, 1] on each slice's total (global) area change. TPS otherwise rescales a slice freely to make landmarks coincide; this measures the net area factor and applies a uniform rescale (about the aligned centroid, so rotation/translation — the alignment pose — are untouched) to pull it back. 0 (default) leaves the fit as-is; 1 fully cancels the global area change so each slice keeps its own area (local warp intact, at the cost of peripheral landmarks coinciding a bit less tightly); values between partially penalize it. See :func:~celldega.align._transform.fit_transform_tps.

0.0
weight_by_adjacent_counts bool

If True (default), weight each shared landmark by the geometric mean of its cell count in the current slice and its neighbor window (a centroid from more cells is a lower-variance estimate) — a landmark with no count (e.g. manually placed) has its count filled in first, per manual_landmark_weight. Set False to weight every landmark equally (manual_landmark_weight has no effect).

True
manual_landmark_weight str

How a landmark with no cell count (e.g. one placed with :class:~celldega.viz.Landmark) is weighted when weight_by_adjacent_counts is True. Its count is filled in from the automated counts sharing this fit step: their mean ("equal", default — a manual landmark carries as much influence as a typical automated one), min ("less" — no more influence than the smallest automated cluster), or max ("greater" — at least as much influence as the largest automated cluster). Landmarks that already have a count are unaffected.

'equal'
compute_residuals bool

If True (default), compute and record each slice's per-landmark leave-one-out residual (see :func:~celldega.align._transform.leave_one_out_residuals) — unlike in-sample residual, this is meaningful even for an exactly-interpolating fit like TPS. Set False to skip the extra refits if landmark counts ever make it costly.

True

Returns:

Type Description
SerialAlignmentTransform

The fitted :class:SerialAlignmentTransform.

Raises:

Type Description
ValueError

If landmarks is missing a required column or has fewer than 2 slices, if reference is out of range, if alignment_window is less than 1, if method is not recognized, if manual_landmark_weight is not recognized, if a slice's landmarks contain duplicate labels, if a slice shares fewer than min_shared_landmarks labels with its neighbor window, or if the fit itself rejects the shared landmarks (e.g. a degenerate configuration for TPS).

calc_landmarks(adatas, cluster_key, slice_attr=None, label_prefix='C-')

Compute one landmark per cluster label, at that cluster's centroid.

Parameters:

Name Type Description Default
adatas AnnData | list[AnnData]

A single AnnData with 2D coordinates in obsm["spatial"] (returns landmarks with no slice tagging, matching the landmarks shape for a single dataset), a list of per-slice AnnData (list order is slice order), or a single combined AnnData with slice_attr given, to be split into slices by that obs column. In the latter two cases the result is tagged with a slice_attr column so it can be passed straight to :func:~celldega.align.serial_slices.align_serial_slices.

required
cluster_key str

obs column with cluster labels to compute centroids for. Cells with a NaN label (e.g. unclustered/QC- filtered cells) are excluded rather than pooled into a spurious "nan" landmark.

required
slice_attr str | None

For a single combined AnnData, the obs column identifying each cell's slice (required in that case, and triggers multi-slice output). For a list of AnnData, the name to give the output's slice-tagging column (default "slice"). Ignored for a single, non-split AnnData.

None
label_prefix str

Prepended to each cluster label to form the landmark label (default "C-", so cluster "0" becomes "C-0"). Keeps automated labels visually identifiable and, in particular, out of the way of :class:~celldega.viz.Landmark's own auto-numbered manual labels (plain integers, "1", "2", ...) — without a prefix the two schemes can collide (e.g. cluster "1" vs. manual landmark "1") once concatenated together. Pass "" to disable.

'C-'

Returns:

Name Type Description
DataFrame

A DataFrame with columns label (label_prefix + cluster

DataFrame

label, as str), x/y (the cluster's centroid, in each

DataFrame

slice's own obsm["spatial"] coordinate space), count (number

DataFrame

of cells in that cluster), and source (always "automated"

DataFrame

here) — plus a slice_attr column when computed over multiple

DataFrame

slices. This is the shape

DataFrame

func:~celldega.align.serial_slices.align_serial_slices's

DataFrame

landmarks parameter expects, so manually-defined landmarks (same

DataFrame

columns, count absent or NaN and source "manual"

see DataFrame

class:~celldega.viz.Landmark) can be combined with this via

DataFrame

func:pandas.concat before being passed in.

Raises:

Type Description
ValueError

If cluster_key is not a column in some slice's obs, if some slice is missing obsm["spatial"] or has fewer than 2 columns there, or (multi-slice mode) if adatas is a single AnnData without slice_attr.

fit_transform_procrustes(source, target, weights=None, allow_scaling=True, allow_reflection=False)

Fit the rotation/scale/translation that best maps source onto target.

Solves the classic Procrustes/Umeyama least-squares problem: minimize sum(weights_i * ||target_i - (scale * rotation @ source_i + translation)||^2) over a rotation, a single uniform scale, and a translation, given n point-to-point correspondences (source[i] corresponds to target[i]).

Parameters:

Name Type Description Default
source ndarray

(n, 2) points to move, n >= 2.

required
target ndarray

(n, 2) corresponding points to match, same order as source.

required
weights ndarray | None

Optional (n,) positive per-point weights (e.g. landmark confidence). None (default) weights every point equally, reproducing the unweighted fit exactly.

None
allow_scaling bool

If False, force a rigid (scale = 1) transform.

True
allow_reflection bool

If False (default), force a proper rotation (det(rotation) == 1) since flipping tissue is not physically valid for serial sections. Set True for contexts where a reflection is legitimate (e.g. some modality-to-modality mappings).

False

Returns:

Type Description
SimilarityTransform

The fitted :class:SimilarityTransform.

Raises:

Type Description
ValueError

If fewer than 2 point pairs are given, shapes mismatch, or weights has the wrong shape or non-positive entries.

fit_transform_tps(source, target, weights=None, smoothing=0.0, degree=1, normalize=True, area_regularization=0.0, shape_regularization=0.0)

Fit a thin-plate-spline warp that maps source landmarks onto target landmarks.

Unlike :func:fit_transform_procrustes, this fits a smooth non-rigid deformation: it matches the landmarks locally rather than one global rotation/scale/translation, so it can recover warps a rigid fit cannot (e.g. non-uniform section stretching). Points far from every landmark fall back toward the affine (degree-1 polynomial) component rather than an arbitrary extrapolation.

Parameters:

Name Type Description Default
source ndarray

(n, 2) landmark points to move, n >= 3 and not all collinear (degree-1 TPS needs 3 affinely independent points).

required
target ndarray

(n, 2) corresponding landmark points to match, same order as source.

required
weights ndarray | None

Optional (n,) positive per-point weights, converted into per-point smoothing (smoothing / weights): a higher-weight landmark gets a smaller effective smoothing (fit more tightly), a lower-weight one gets more slack. Only has an effect when smoothing > 0 — at smoothing = 0 the spline interpolates every landmark exactly regardless of weight, since there's no such thing as a weighted exact interpolation.

None
smoothing float

Bending-energy penalty trading exact landmark matching (0, the default — the spline passes through every landmark, which overfits noisy cluster-centroid landmarks by contorting the tissue to hit each one) against a smoother, stiffer warp (higher values relax the fit toward the plain affine transform, preserving each slice's own shape). With normalize=True this is measured in normalized domain units, so it's comparable across datasets regardless of coordinate scale — useful values are roughly 0 (exact) through ~0.1 to 1 (light local warp) to ~10+ (nearly rigid/affine). Without normalization it is in raw kernel units (~distance² · log distance), so for micron coordinates it would need to be enormous (~1e6+) to have any effect at all.

0.0
degree int

Degree of the polynomial term added to the spline (1 gives an affine fallback away from the landmarks).

1
normalize bool

If True (default), the source (domain) is recentered and scaled to unit RMS radius before fitting, and query points are normalized the same way on apply. This makes smoothing scale-free (see above) and improves numerical conditioning. It does not change a smoothing=0 fit (exact interpolation is exact in any units) — only the meaning of a nonzero smoothing.

True
area_regularization float

Penalty in [0, 1] on the warp's total (global) area change. TPS's affine component freely rescales a slice to make landmarks coincide, undesirable when slices are genuinely different sizes. See below for how it and shape_regularization are applied together.

0.0
shape_regularization float

Penalty in [0, 1] on the warp's global proportion change — the affine's anisotropy (its two singular values' ratio, i.e. stretching one axis while squeezing the other, plus shear). This is separate from area: an affine can keep area constant while still distorting proportions (a taller, pinched-in-the-middle look), which area_regularization alone won't catch.

Both are applied as a single post-fit correction: the warp's global affine A (best-fit linear map of the landmark cloud) is SVD'd into a rotation and two singular values; the geometric-mean scale is pulled toward 1 by area_regularization and the anisotropy toward 1 by shape_regularization, then a uniform correction is applied about the output centroid — leaving rotation, translation, and local (bending) warp untouched. At 0/0 (default) the fit is unchanged; 1/1 makes the global part rigid (rotation only — area and proportions both preserved), leaving only local deformation.

0.0

Returns:

Type Description
ThinPlateSplineTransform

The fitted :class:ThinPlateSplineTransform.

Raises:

Type Description
ValueError

If fewer than 3 point pairs are given, shapes mismatch, weights has the wrong shape or non-positive entries, area_regularization/shape_regularization are negative, or the landmarks are degenerate (e.g. collinear).

leave_one_out_residuals(source, target, fit_transform, weights=None)

Per-landmark leave-one-out residual: how well does the fit implied by the other landmarks predict this one?

In-sample residual is a poor diagnostic for an interpolating fit (e.g. :func:fit_transform_tps at smoothing=0 matches every landmark exactly by construction, regardless of whether the landmarks are actually consistent). Leave-one-out residual instead measures, for each landmark, whether it agrees with a fit built from everything except it — a large value flags a landmark that may be mislabeled, noisy, or otherwise inconsistent with the rest.

Parameters:

Name Type Description Default
source ndarray

(n, 2) landmark points.

required
target ndarray

(n, 2) corresponding landmark points.

required
fit_transform Callable[..., Transform]

A fit(source, target, weights=None) -> Transform callable, e.g. :func:fit_transform_procrustes or :func:fit_transform_tps (bind extra keyword arguments with :func:functools.partial).

required
weights ndarray | None

Optional (n,) positive per-point weights, passed through to fit_transform for both the leave-one-out fits.

None

Returns:

Type Description
ndarray

An (n,) array of residual distances, NaN for any landmark

ndarray

whose leave-one-out refit failed (e.g. too few or degenerate points

ndarray

remained).

load_transform(path)

Load a transform previously saved with :func:save_transform.

Parameters:

Name Type Description Default
path str | Path

Path to the .npz file.

required

Returns:

Type Description
Transform

The reconstructed :class:SimilarityTransform or

Transform

class:ThinPlateSplineTransform — for the latter, a fresh

Transform

RBFInterpolator rebuilt from the saved plain-array inputs,

Transform

numerically equivalent to the one originally fitted.

Raises:

Type Description
ValueError

If the file's kind is not recognized.

neighborhood_alignment(shapes, initial_transform, slice_attr='slice_id', cluster_attr='cluster_id', alignment_window=None, n_sweeps=2, rotation_range=10.0, translation_range=0.1, n_rotation_grid=7, distance_weight='inverse', distance_decay=1.0, simplify_tolerance=None, min_shared_clusters=1, compute_diagnostics=True, progress_every=0)

Refine a serial-slice alignment by maximizing neighborhood polygon overlap.

Starts from initial_transform (a cluster-centroid Procrustes fit from :func:~celldega.align.serial_slices.calc_alignment_transform) and adds, per slice, a small residual rigid transform (rotation + translation, never scale) chosen to maximize the summed intersection area of corresponding neighborhood regions across a window of neighboring slices. This recovers alignment that region centroids miss — footprint orientation and bilateral structure a centroid averages away — while staying rigid and reusing the Procrustes fit as a starting point close enough for the (nonsmooth) overlap objective to optimize locally. The regions are typically cluster alpha shapes, but any per-(slice, region) polygons with labels shared across slices work (manually drawn regions, other domain-ID algorithms).

Optimization is block coordinate descent: the reference slice is held fixed, and every other slice is optimized in turn against its neighbors' current estimates, in alternating forward and backward passes over n_sweeps sweeps. Each per-slice step coarse-searches residual rotation on a grid, then refines all three parameters with a bounded Powell search (see :func:_refine_one_slice).

Parameters:

Name Type Description Default
shapes GeoDataFrame

A per-(slice, region) polygon GeoDataFrame in each slice's native coordinate frame, with slice_attr/cluster_attr columns and a geometry column — typically the cluster alpha shapes :func:~celldega.nbhd.alpha_shapes.alpha_shape_cell_clusters_by_slice returns, but any polygons with labels shared across slices (manual regions, other domain-ID algorithms) work. initial_transform is applied to these, so they must be pre-alignment (do not pass shapes computed from already-aligned coordinates). Geometry may be 2D or 3D (a Z stamp is flattened away).

required
initial_transform SerialAlignmentTransform

The Procrustes :class:~celldega.align.serial_slices.SerialAlignmentTransform to refine. Its slice order, reference slice, and (unless overridden) alignment window are reused, and its landmarks are carried through as provenance. Every slice present in shapes must be one of its slices.

required
slice_attr str

Column in shapes identifying each row's slice (default "slice_id", matching the neighborhood-cloud output). Its values must match initial_transform's slice ids. Note this is the input column name; the returned transform keeps initial_transform's own slice_attr.

'slice_id'
cluster_attr str

Column in shapes identifying each row's region/label (default "cluster_id"). A region is matched to its counterpart in another slice by this value, so it need not be a cluster — any label consistent across slices (a manual region name, a domain id) works.

'cluster_id'
alignment_window int | None

Number of neighboring slices on each side to score a slice's overlap against. None (default) reuses initial_transform's alignment_window.

None
n_sweeps int

Number of forward+backward coordinate-descent sweeps over the non-reference slices.

2
rotation_range float

Bound (in degrees) on each slice's residual rotation, ± this value. Kept modest since the Procrustes fit already resolves gross rotation.

10.0
translation_range float

Bound on each slice's residual translation, as a fraction of the overall tissue width (largest x/y extent across all shapes), applied ± in each axis.

0.1
n_rotation_grid int

Number of grid points in the coarse residual-rotation search over [-rotation_range, +rotation_range] before the Powell refinement. 0 skips the grid (Powell only).

7
distance_weight str

How a neighbor's overlap contribution falls off with slice separation d (all modes give an adjacent neighbor weight 1.0): "inverse" (default, 1/d), "uniform" (all neighbors in the window weighted equally), "exponential" (exp(-(d-1)/distance_decay)), or "gaussian" (exp(-(d-1)**2 / (2*distance_decay**2)), a slow-then-sharp tail-off that concentrates the window on the closest slices).

'inverse'
distance_decay float

Falloff scale for the "exponential"/"gaussian" weights (larger = slower falloff, so farther slices keep more influence); ignored by "inverse"/"uniform". Default 1.0.

1.0
simplify_tolerance float | None

If given, Douglas-Peucker tolerance applied to each alpha shape before optimization, to speed up intersection at the cost of boundary detail. None (default) leaves shapes as-is.

None
min_shared_clusters int

A slice is only refined if it shares at least this many cluster labels with its neighbor window; otherwise its residual stays identity (it keeps its Procrustes transform) and it is marked skipped in the transform log.

1
compute_diagnostics bool

If True (default), record per-slice overlap before/after refinement and per-cluster IoU/coverage against the nearest neighbor in the transform log.

True
progress_every int

If > 0, print a progress line (with elapsed time) at each phase/sweep and every progress_every per-slice optimizations — useful on a large stack where a run takes minutes. 0 (default) is silent.

0

Returns:

Type Description
SerialAlignmentTransform

A refined :class:~celldega.align.serial_slices.SerialAlignmentTransform

SerialAlignmentTransform

with method="neighborhood", each slice's transform being its

SerialAlignmentTransform

residual delta composed with its initial Procrustes transform, ready to

SerialAlignmentTransform

pass to :func:~celldega.align.serial_slices.align_serial_slices.

Raises:

Type Description
ValueError

If distance_weight is not recognized, shapes is missing a required column, shapes references a slice not in initial_transform, alignment_window is less than 1, or the initial transforms are not rigid (a residual rigid refinement is only well-defined on top of a rigid, e.g. method="procrustes", initial fit — not a thin-plate-spline warp).

plot_alignment(transform, adatas=None, slice_attr=None, color_by='slice', max_cells_per_slice=20000, cell_size=1.0, figsize=(12, 6), random_state=0)

Side-by-side 2D scatter of an alignment, before vs. after fitting.

"Before" is each slice in its own native coordinates; "after" is every slice warped into the reference slice's frame by its fitted transform.

Pass adatas (strongly recommended) to overlay each slice's actual cell centroids underneath the landmarks — that's what actually shows whether the tissue aligns. Landmarks alone are a poor check: an interpolating fit lands every landmark exactly on top of its match by construction, so the landmark-only "after" panel looks perfect regardless of how well the surrounding tissue really lines up.

Parameters:

Name Type Description Default
transform SerialAlignmentTransform

A fitted :class:~celldega.align.serial_slices.SerialAlignmentTransform.

required
adatas AnnData | list[AnnData] | None

The same slices used to fit transform (a single combined AnnData with slice_attr, or a list of per-slice AnnData). If given, their obsm["spatial"] centroids are drawn faintly (colored by slice) under the landmarks, before and after. If None (default), only landmarks are plotted.

None
slice_attr str | None

For a single combined AnnData, the obs column identifying each cell's slice. Defaults to transform's own slice_attr.

None
color_by str

"slice" (default) or "label" — how the landmarks are colored. Cell centroids are always colored by slice (they have no landmark label).

'slice'
max_cells_per_slice int

Randomly subsample each slice's centroids to at most this many before plotting (0 disables subsampling), to keep the figure light for large slices. The subsample is only for display — it never touches the fit.

20000
cell_size float

Marker size for cell centroids (landmarks are drawn larger, on top).

1.0
figsize tuple[float, float]

Passed to :func:matplotlib.pyplot.subplots.

(12, 6)
random_state int

Seed for the subsampling RNG, so the figure is reproducible.

0

Returns:

Type Description
Figure

(fig, (ax_before, ax_after)) — call fig.show() or

tuple[Axes, Axes]

fig.savefig(...) yourself; this never calls plt.show().

Raises:

Type Description
ValueError

If color_by isn't "slice" or "label".

save_transform(transform, path)

Save a fitted :class:SimilarityTransform or :class:ThinPlateSplineTransform to a plain .npz file — no pickle involved, so the result is portable, inspectable with any numpy install, and doesn't depend on matching library versions the way a pickled object graph would. A :class:ThinPlateSplineTransform is saved as the plain-array inputs (landmark positions, per-point smoothing, kernel, epsilon, degree, plus the source normalization center/scale) that reconstruct an equivalent RBFInterpolator, not the fitted object itself.

Parameters:

Name Type Description Default
transform Transform

The transform to save.

required
path str | Path

Destination .npz file path.

required

Raises:

Type Description
TypeError

If transform is neither a :class:SimilarityTransform nor a :class:ThinPlateSplineTransform.

transform_shapes(shapes, transform, slice_attr='slice_id')

Apply a serial-slice transform to an alpha-shape GeoDataFrame.

Moves each shape's geometry by its slice's fitted transform, in place of recomputing alpha shapes from transformed cell coordinates. Handy for inspecting or plotting the refined neighborhood shapes (the polygon companion to :meth:~celldega.align.serial_slices.SerialAlignmentTransform.apply_to_points). Any Z coordinate is passed through unchanged, so note it does not restamp the aligned per-slice Z — for a 3D NeighborhoodCloud, write the shapes with :func:~celldega.align.write_nbhd_cloud (which recomputes from the aligned coordinates and stamps Z consistently) rather than feeding transformed shapes directly. The area column, if present, is recomputed from the transformed (rigid, so area-preserving) geometry.

Parameters:

Name Type Description Default
shapes GeoDataFrame

Alpha shapes with a geometry column and a slice_attr column whose values are transform's slice ids — e.g. the output of :func:~celldega.nbhd.alpha_shapes.alpha_shape_cell_clusters_by_slice.

required
transform SerialAlignmentTransform

A fitted :class:~celldega.align.serial_slices.SerialAlignmentTransform (e.g. from :func:neighborhood_alignment).

required
slice_attr str

Column in shapes naming each row's slice (default "slice_id", the neighborhood-cloud convention).

'slice_id'

Returns:

Type Description
GeoDataFrame

A copy of shapes with transformed geometry (and updated area).

GeoDataFrame

Rows whose slice id is not in transform are left unchanged.

write_alignment_point_cloud(adata, dega_files_dir, alignment_name, *, z_key='Z', cluster_key=None, write_genes=None, write_base_metadata=None, overwrite=False)

Write aligned 3D cell centroids to a point-cloud DegaFiles as a named alignment.

Parameters

adata : AnnData Aligned AnnData. obsm["spatial"] provides x/y (its first two columns); z is taken from obs[z_key] if present, else from a third spatial column, else 0. Cell names come from obs_names. dega_files_dir : str or Path Target point-cloud DegaFiles directory. If it already contains landscape_parameters.json the writer appends (positions + registration only); otherwise it creates a fresh point-cloud directory. alignment_name : str Name of this alignment variant. The positions are written to cell_metadata_<alignment_name>.parquet and the name is registered in landscape_parameters.json under "alignments". View it with Landscape(..., alignment="<alignment_name>"). z_key : str, default "Z" obs column holding the per-slice z (depth) coordinate. cluster_key : str, optional Only used in create mode: obs column to export as clusters (with colors from uns[f"{cluster_key}_colors"] when available). Ignored in append mode, where existing clusters are reused. write_genes : bool, optional Only used in create mode. Whether to export gene expression to cbg/ + meta_gene.parquet (keyed by cell name). Defaults to True when adata carries an expression matrix (layers['counts'] or X) and False otherwise. Ignored in append mode, where existing gene data is reused. write_base_metadata : bool, optional Whether to also (over)write the base cell_metadata.parquet with these positions. Defaults to True in create mode and False in append mode (so an append never clobbers the existing default positions). overwrite : bool, default False Allow overwriting an existing cell_metadata_<alignment_name>.parquet.

Returns

pathlib.Path Path to the written cell_metadata_<alignment_name>.parquet.

write_nbhd_cloud(adata, dega_files_dir, *, cluster_attr='cluster', slice_attr='slice_id', z_attr=None, alphas=(150,), z_jitter=0.1, meta_cluster=None, save_genes=True, progress_every=1, max_cell_scatter=50000, cell_scatter_random_state=0, compute_gene_nbhds=False, gene_list=None, gene_min_expression=2.0, gene_min_cells=4, gene_max_cells=50000, gene_shape_max_cells=50000, gene_random_state=0, gene_z_jitter=None, gene_progress_every=500)

Write a full neighborhood-cloud DegaFiles set from an aligned AnnData.

Computes one real alpha-shape polygon per (slice, cluster) — using celldega.nbhd's alpha-shape machinery, the same well-tested geometry code the rest of celldega already relies on, not a separate/duplicated implementation — and writes the full DegaFile layout (meta_slice.parquet, shapes/by_slice/, cells/by_cluster/, meta_neighborhood.parquet, cell_clusters/meta_cluster.parquet, landscape_parameters.json).

Gene-nbhds (a curated marker-gene list's own alpha shapes, "peppered" with real expressing-cell centroids) are off by default: a real alpha shape per gene is expensive enough — and only useful for a deliberately chosen list of marker genes, not the whole transcriptome — that auto-computing it isn't the right default. Pass compute_gene_nbhds=True with an explicit gene_list to also write shapes/by_gene/ + cells/by_gene/ for those genes.

Works on any AnnData with the right obs/obsm columns, not just the output of celldega.align specifically — e.g. straight off align_serial_slices (which already sets obsm["spatial"], obs["Z"], and carries whatever cluster/slice columns were used for landmarks), or any other aligned 3D AnnData with equivalent columns.

Parameters

adata : AnnData Aligned 3D cell-level AnnData: obsm["spatial"] (x, y), obs[cluster_attr], obs[slice_attr], optionally obs[z_attr]. For gene-nbhds, also needs a real expression matrix in .X (adata[:, gene].X must be numeric counts/expression, not a placeholder). dega_files_dir : str | Path Output DegaFiles root directory (created if missing). Under the one-alignment-per-cloud convention this directory name is the NeighborhoodCloud's identity — write a new alignment to a new directory. cluster_attr, slice_attr, z_attr : str, str, str | None See celldega.nbhd.alpha_shape_cell_clusters_by_slice. alphas : Sequence[float] Single inverse-alpha resolution used for both cluster shapes and (if requested) gene shapes — see alpha_shape_cell_clusters_by_slice. z_jitter : float Per-cluster Z offset within a slice, to avoid z-fighting between coplanar cluster polygons (see alpha_shape_cell_clusters_by_slice). meta_cluster : pd.DataFrame | None Optional cluster color/metadata lookup. Without it, colors come from adata.uns[f"{cluster_attr}_colors"] if present (the usual place scanpy leaves them after sc.tl.leiden/sc.pl.umap), else a fallback palette (so neighborhoods aren't all black). save_genes : bool Whether to write gene-expression data. When True (default), meta_gene.parquet (the dataset-root per-gene stats the gene search box/bar graph read) is written; when False, all gene-expression output is skipped for a lean, clusters-only cloud (useful for label-only AnnData, or when gene coloring isn't needed). Must be True to also compute gene-nbhds (compute_gene_nbhds). progress_every : int Print a progress line every this many slices while computing cluster shapes (default 1, i.e. every slice — a real alpha shape per (slice, cluster) is the slow part of this call on a large aligned dataset with many slices). 0 disables it. max_cell_scatter : int | None Cap (via uniform random subsample) on the cells written per cluster to cells/by_cluster/cluster_<id>.parquet — this is the same "cap peppering to a scatter" idea used for genes (see gene_max_cells below), applied to cluster cells, which today have no other bound. None writes every cell in the cluster (the original, uncapped behavior). Default 50_000. cell_scatter_random_state : int Seed for max_cell_scatter's subsampling RNG. compute_gene_nbhds : bool Whether to also compute and write gene-nbhds. Default False — see above. Requires gene_list. gene_list : Sequence[str] | None Genes to compute shapes for when compute_gene_nbhds=True. Required in that case — deliberately not auto-selected from the whole gene panel, since a real alpha shape per gene is too expensive to want by accident. Ignored otherwise. gene_min_expression, gene_min_cells, gene_max_cells, gene_progress_every : Forwarded to celldega.pre.write_gene_shapes_streaming — see its docstring for min_expression/min_cells/max_cells/ progress_every. gene_shape_max_cells : int | None Cap (via uniform random subsample) on the expressing cells that feed a gene's alpha shape's own geometry computation, forwarded to write_gene_shapes_streaming. This is what actually bounds the expensive part of a broadly-expressed gene's shape -- even a small, curated gene_list (tens to ~100 marker genes) can include a gene expressed in far more cells than are needed to describe its spatial footprint. None disables the cap. Default 50_000. gene_random_state : int Seed for gene_shape_max_cells's subsampling RNG. gene_z_jitter : float | None Per-gene Z offset (see write_gene_shapes_streaming). Defaults to z_jitter (the same value used for cluster shapes) if not given.

Returns

pathlib.Path dega_files_dir, as a Path.

Examples

from celldega.align import write_nbhd_cloud write_nbhd_cloud(adata_aligned, "my_dataset_nbhd_cloud")

Later, once you know which marker genes you care about:

write_nbhd_cloud( ... adata_aligned, ... "my_dataset_nbhd_cloud", ... compute_gene_nbhds=True, ... gene_list=["Matn1", "Col2a1", "Col1a1"], ... )