kintsugi-st 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kintsugi/__init__.py ADDED
@@ -0,0 +1,168 @@
1
+ """Kintsugi: adaptive spatial tessellation for sub-cellular transcriptomics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .models import TessellationResult
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "GridData",
15
+ "TessellationReport",
16
+ "TessellationResult",
17
+ "build_regular_grid",
18
+ "load_10x_feature_matrix",
19
+ "load_visium_hd",
20
+ "load_visium_hd_from_dir",
21
+ "parse_visium_barcode_coordinates",
22
+ "read_tissue_positions",
23
+ "validate_grid_data",
24
+ "adaptive_tessellation",
25
+ "aggregate_counts",
26
+ "boundary_tensor",
27
+ "build_spatial_graph",
28
+ "directional_semivariance",
29
+ "poisson_baseline",
30
+ "poisson_log_variance",
31
+ "make_toy_dataset",
32
+ "tessellate",
33
+ "tessellation_report",
34
+ "to_anndata",
35
+ ]
36
+
37
+ _LAZY_EXPORTS = {
38
+ "GridData": (".models", "GridData"),
39
+ "TessellationReport": (".report", "TessellationReport"),
40
+ "TessellationResult": (".models", "TessellationResult"),
41
+ "validate_grid_data": (".models", "validate_grid_data"),
42
+ "build_regular_grid": (".io", "build_regular_grid"),
43
+ "load_10x_feature_matrix": (".io", "load_10x_feature_matrix"),
44
+ "load_visium_hd": (".io", "load_visium_hd"),
45
+ "load_visium_hd_from_dir": (".io", "load_visium_hd_from_dir"),
46
+ "parse_visium_barcode_coordinates": (".io", "parse_visium_barcode_coordinates"),
47
+ "read_tissue_positions": (".io", "read_tissue_positions"),
48
+ "to_anndata": (".io", "to_anndata"),
49
+ "directional_semivariance": (".variogram", "directional_semivariance"),
50
+ "poisson_baseline": (".variogram", "poisson_baseline"),
51
+ "poisson_log_variance": ("._poisson_log_var", "poisson_log_variance"),
52
+ "boundary_tensor": (".tensor", "boundary_tensor"),
53
+ "adaptive_tessellation": (".partition", "adaptive_tessellation"),
54
+ "aggregate_counts": (".aggregate", "aggregate_counts"),
55
+ "build_spatial_graph": (".graph", "build_spatial_graph"),
56
+ "tessellation_report": (".report", "tessellation_report"),
57
+ "make_toy_dataset": ("._demo", "make_toy_dataset"),
58
+ }
59
+
60
+
61
+ def __getattr__(name: str):
62
+ if name not in _LAZY_EXPORTS:
63
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
64
+ from importlib import import_module
65
+
66
+ module_name, attr_name = _LAZY_EXPORTS[name]
67
+ value = getattr(import_module(module_name, __name__), attr_name)
68
+ globals()[name] = value
69
+ return value
70
+
71
+
72
+ def tessellate(
73
+ counts,
74
+ rows: int | None = None,
75
+ cols: int | None = None,
76
+ mask=None,
77
+ lag: int = 2,
78
+ kappa: float = 2.0,
79
+ min_seed_distance: int = 4,
80
+ smooth_sigma: float = 4.0,
81
+ ) -> "TessellationResult":
82
+ """Run the full Kintsugi pipeline: variogram -> tensor -> partition -> aggregate -> graph.
83
+
84
+ Parameters
85
+ ----------
86
+ counts : GridData or sparse matrix
87
+ Either a normalized ``GridData`` container or a sparse UMI count
88
+ matrix with shape ``(rows * cols, genes)`` in row-major grid order.
89
+ rows, cols : int
90
+ Grid dimensions. Required when ``counts`` is not a ``GridData`` instance.
91
+ mask : ndarray, shape (rows, cols), dtype bool, optional
92
+ True for in-tissue bins. Ignored when ``counts`` is a ``GridData`` instance.
93
+ lag : int
94
+ Variogram lag in bin units (2 bins = 4 um on a 2 um grid).
95
+ kappa : float
96
+ Stationarity tolerance (in SE units) for region growing.
97
+ min_seed_distance : int
98
+ Minimum seed separation in bins.
99
+ smooth_sigma : float
100
+ Gaussian sigma (bins) for trace smoothing before seed detection.
101
+
102
+ Returns
103
+ -------
104
+ result : TessellationResult with fields
105
+ ``"labels"`` : ndarray (rows, cols) int32, region labels (0-indexed, -1 outside mask)
106
+ ``"residuals"`` : ndarray (K, G) float64, Pearson residuals
107
+ ``"areas"`` : ndarray (K,) float64, bins per region
108
+ ``"depths"`` : ndarray (K,) float64, total UMI per region
109
+ ``"centroids"`` : ndarray (K, 2) float64, (row, col) centroids
110
+ ``"adjacency"`` : csr_matrix (K, K), spatial adjacency graph
111
+ ``"trace"`` : ndarray (rows, cols) float64, boundary-tensor trace
112
+ """
113
+ from .models import GridData, TessellationResult
114
+
115
+ if isinstance(counts, GridData):
116
+ if rows is not None or cols is not None or mask is not None:
117
+ raise ValueError(
118
+ "rows, cols, and mask must not be passed separately when counts is a GridData instance."
119
+ )
120
+ grid = counts
121
+ counts = grid.counts
122
+ rows = grid.rows
123
+ cols = grid.cols
124
+ mask = grid.mask
125
+ else:
126
+ if rows is None or cols is None:
127
+ raise TypeError(
128
+ "rows and cols are required when counts is not a GridData instance."
129
+ )
130
+ grid = GridData(counts, rows=rows, cols=cols, mask=mask)
131
+ counts = grid.counts
132
+ rows = grid.rows
133
+ cols = grid.cols
134
+ mask = grid.mask
135
+
136
+ import numpy as np
137
+
138
+ from .aggregate import aggregate_counts
139
+ from .graph import build_spatial_graph
140
+ from .partition import adaptive_tessellation
141
+ from .tensor import boundary_tensor
142
+ from .variogram import directional_semivariance
143
+
144
+ umi_flat = np.asarray(counts.sum(axis=1)).ravel()
145
+ umi = umi_flat.reshape(rows, cols)
146
+
147
+ excess = directional_semivariance(umi, lag=lag, mask=mask)
148
+ trace, _lambda1, _lambda2, evec1 = boundary_tensor(excess)
149
+ labels = adaptive_tessellation(
150
+ umi, trace, evec1,
151
+ kappa=kappa,
152
+ min_seed_distance=min_seed_distance,
153
+ smooth_sigma=smooth_sigma,
154
+ mask=mask,
155
+ )
156
+
157
+ residuals, areas, depths, centroids = aggregate_counts(counts, labels, mask=mask)
158
+ adjacency = build_spatial_graph(labels)
159
+
160
+ return TessellationResult(
161
+ labels=labels,
162
+ residuals=residuals,
163
+ areas=areas,
164
+ depths=depths,
165
+ centroids=centroids,
166
+ adjacency=adjacency,
167
+ trace=trace,
168
+ )
kintsugi/_demo.py ADDED
@@ -0,0 +1,169 @@
1
+ """Built-in demo: toy data generation and one-command pipeline.
2
+
3
+ This module is shipped inside the wheel so that ``pip install`` users
4
+ can run the demo without cloning the repository::
5
+
6
+ kintsugi-demo # CLI entry point
7
+ python -m kintsugi.demo # module invocation
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import time
14
+
15
+ import numpy as np
16
+ import scipy.sparse as sp
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Toy dataset (self-contained, no external dependency)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def make_toy_dataset(
24
+ rows: int = 60,
25
+ cols: int = 60,
26
+ n_genes: int = 100,
27
+ seed: int = 2024,
28
+ ):
29
+ """Create a synthetic spatial transcriptomics grid.
30
+
31
+ Returns a ``kintsugi.GridData`` with four spatially distinct domains:
32
+
33
+ - **Top-left** (domain 0): high density, marker genes in first quarter.
34
+ - **Top-right** (domain 1): medium density, marker genes in second quarter.
35
+ - **Bottom-left** (domain 2): medium density, marker genes in third quarter.
36
+ - **Bottom-right** (domain 3): low density, marker genes in last quarter.
37
+
38
+ A circular tissue mask excludes corner bins.
39
+
40
+ Parameters
41
+ ----------
42
+ rows, cols : int
43
+ Grid dimensions (default 60×60 = 3,600 bins).
44
+ n_genes : int
45
+ Number of genes (default 100).
46
+ seed : int
47
+ Random seed for reproducibility.
48
+
49
+ Returns
50
+ -------
51
+ grid : kintsugi.GridData
52
+ Normalized grid ready for ``grid.tessellate()``.
53
+ """
54
+ from . import GridData
55
+
56
+ rng = np.random.default_rng(seed)
57
+
58
+ rr, cc = np.mgrid[:rows, :cols]
59
+ centre_r, centre_c = rows / 2 - 0.5, cols / 2 - 0.5
60
+ dist = np.sqrt((rr - centre_r) ** 2 + (cc - centre_c) ** 2)
61
+ mask = dist <= 28.0
62
+
63
+ domain = np.full((rows, cols), -1, dtype=np.int32)
64
+ domain[(rr < rows // 2) & (cc < cols // 2)] = 0
65
+ domain[(rr < rows // 2) & (cc >= cols // 2)] = 1
66
+ domain[(rr >= rows // 2) & (cc < cols // 2)] = 2
67
+ domain[(rr >= rows // 2) & (cc >= cols // 2)] = 3
68
+
69
+ base_rates = {0: 12.0, 1: 7.0, 2: 7.0, 3: 4.0}
70
+
71
+ block = n_genes // 4
72
+ gene_profiles = np.ones((4, n_genes)) * 0.1
73
+ gene_profiles[0, 0:block] = 2.0
74
+ gene_profiles[1, block:2 * block] = 2.0
75
+ gene_profiles[2, 2 * block:3 * block] = 2.0
76
+ gene_profiles[3, 3 * block:n_genes] = 2.0
77
+ gene_profiles = gene_profiles / gene_profiles.sum(axis=1, keepdims=True)
78
+
79
+ N = rows * cols
80
+ data_rows = []
81
+ data_cols = []
82
+ data_vals = []
83
+
84
+ for r in range(rows):
85
+ for c in range(cols):
86
+ if not mask[r, c]:
87
+ continue
88
+ d = domain[r, c]
89
+ if d < 0:
90
+ continue
91
+ total_umi = rng.poisson(base_rates[d])
92
+ if total_umi == 0:
93
+ continue
94
+ gene_counts = rng.multinomial(total_umi, gene_profiles[d])
95
+ nz_idx = np.where(gene_counts > 0)[0]
96
+ flat_idx = r * cols + c
97
+ for g in nz_idx:
98
+ data_rows.append(flat_idx)
99
+ data_cols.append(g)
100
+ data_vals.append(float(gene_counts[g]))
101
+
102
+ counts = sp.coo_matrix(
103
+ (data_vals, (data_rows, data_cols)),
104
+ shape=(N, n_genes),
105
+ ).tocsr()
106
+
107
+ gene_names = np.array([f"Gene_{g:03d}" for g in range(n_genes)])
108
+
109
+ return GridData(
110
+ counts, rows=rows, cols=cols, mask=mask, gene_names=gene_names,
111
+ )
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Demo entry point
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def main() -> None:
119
+ """Run the full Kintsugi demo pipeline."""
120
+ from . import __version__, tessellation_report, to_anndata
121
+
122
+ print(f"Kintsugi v{__version__}")
123
+ print("=" * 55)
124
+
125
+ print("\n1. Generating toy dataset...")
126
+ t0 = time.perf_counter()
127
+ grid = make_toy_dataset()
128
+ t_data = time.perf_counter() - t0
129
+ print(f" Grid: {grid.rows}\u00d7{grid.cols}, {grid.n_genes} genes, "
130
+ f"{int(grid.mask.sum()):,} tissue bins")
131
+ print(f" Time: {t_data:.2f}s")
132
+
133
+ print("\n2. Running tessellation...")
134
+ t0 = time.perf_counter()
135
+ result = grid.tessellate()
136
+ t_tess = time.perf_counter() - t0
137
+ print(f" Regions: {result.n_regions}")
138
+ print(f" Time: {t_tess:.2f}s")
139
+
140
+ print("\n3. Diagnostic report:")
141
+ t0 = time.perf_counter()
142
+ report = tessellation_report(result, grid)
143
+ t_report = time.perf_counter() - t0
144
+ print(report)
145
+ print(f" Report time: {t_report:.2f}s")
146
+
147
+ label_hash = hashlib.sha256(result.labels.tobytes()).hexdigest()[:16]
148
+ print("\n4. Determinism check:")
149
+ print(f" Label hash (sha256[:16]): {label_hash}")
150
+
151
+ print("\n5. AnnData export:")
152
+ try:
153
+ adata = to_anndata(result, grid=grid, use_raw_counts=True)
154
+ print(f" Shape: {adata.shape}")
155
+ print(f" obs: {list(adata.obs.columns)}")
156
+ print(f" obsm: {list(adata.obsm.keys())}")
157
+ print(f" obsp: {list(adata.obsp.keys())}")
158
+ print(f" layers: {list(adata.layers.keys())}")
159
+ except ImportError:
160
+ print(" Skipped (anndata not installed).")
161
+ print(" Install with: pip install 'kintsugi-st[anndata]'")
162
+
163
+ total = t_data + t_tess + t_report
164
+ print(f"\nTotal runtime: {total:.2f}s")
165
+ print("Done.")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,104 @@
1
+ """Poisson-log variance: Var[log(N+1)] for N ~ Poisson(lambda).
2
+
3
+ The trigamma function psi^(1)(lambda+1) equals Var[log(X)] for
4
+ X ~ Gamma(lambda+1, 1), which is a continuous approximation. The exact
5
+ discrete quantity requires summing over the Poisson PMF. The two
6
+ converge as lambda -> inf (relative error < 0.8% for lambda >= 10),
7
+ but diverge substantially at small lambda (2.69x overestimate at
8
+ lambda = 1).
9
+
10
+ This module provides a vectorized implementation using a precomputed
11
+ lookup table with linear interpolation for lambda in [0, 500], and
12
+ falls back to the trigamma for lambda > 500 (relative error < 1e-8).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+ from scipy.special import gammaln, polygamma
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Scalar computation (used to build the lookup table)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def _exact_scalar(lam: float) -> float:
26
+ """Exact Var[log(N+1)] for a single lambda value."""
27
+ if lam <= 0.0:
28
+ return 0.0
29
+
30
+ # Truncation bound: tail probability < 1e-15.
31
+ n_max = int(max(20, lam + 12.0 * np.sqrt(lam) + 10))
32
+ n = np.arange(0, n_max + 1, dtype=np.float64)
33
+
34
+ # Log-space Poisson PMF for numerical stability.
35
+ log_pmf = n * np.log(lam) - lam - gammaln(n + 1.0)
36
+ pmf = np.exp(log_pmf)
37
+
38
+ log_np1 = np.log(n + 1.0)
39
+
40
+ m1 = np.dot(pmf, log_np1) # E[log(N+1)]
41
+ m2 = np.dot(pmf, log_np1 ** 2) # E[log(N+1)^2]
42
+ return float(m2 - m1 * m1)
43
+
44
+
45
+ _LUT_STEP = 0.1
46
+ _LUT_MAX = 500.0
47
+ _LUT_N = int(_LUT_MAX / _LUT_STEP) + 1
48
+ _LUT_LAMBDAS: np.ndarray | None = None
49
+ _LUT_VALUES: np.ndarray | None = None
50
+
51
+
52
+ def _lookup_table() -> tuple[np.ndarray, np.ndarray]:
53
+ global _LUT_LAMBDAS, _LUT_VALUES
54
+ if _LUT_LAMBDAS is None or _LUT_VALUES is None:
55
+ lambdas = np.linspace(0.0, _LUT_MAX, _LUT_N)
56
+ values = np.array([_exact_scalar(lam) for lam in lambdas], dtype=np.float64)
57
+ _LUT_LAMBDAS = lambdas
58
+ _LUT_VALUES = values
59
+ return _LUT_LAMBDAS, _LUT_VALUES
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Public vectorized function
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def poisson_log_variance(lam: np.ndarray | float) -> np.ndarray | float:
67
+ r"""Approximate Var[log(N+1)] for N ~ Poisson(lam), vectorized.
68
+
69
+ For lam <= 500: linear interpolation on a lazily built exact table
70
+ (absolute error < 1e-4 at worst, typically < 1e-5).
71
+ For lam > 500: trigamma(lam+1) (relative error < 1e-8).
72
+
73
+ Parameters
74
+ ----------
75
+ lam : array_like
76
+ Poisson rate parameter(s). Must be non-negative.
77
+
78
+ Returns
79
+ -------
80
+ var : same shape as lam
81
+ Var[log(N+1)] for each element.
82
+ """
83
+ scalar = np.ndim(lam) == 0
84
+ lam_arr = np.asarray(lam, dtype=np.float64)
85
+ if np.any(~np.isfinite(lam_arr)) or np.any(lam_arr < 0):
86
+ raise ValueError("lam must contain only finite non-negative values.")
87
+
88
+ shape = lam_arr.shape
89
+ flat = lam_arr.ravel()
90
+
91
+ result = np.empty_like(flat)
92
+
93
+ lo = flat <= _LUT_MAX
94
+ hi = ~lo
95
+
96
+ if np.any(lo):
97
+ lut_lambdas, lut_values = _lookup_table()
98
+ result[lo] = np.interp(flat[lo], lut_lambdas, lut_values)
99
+ if np.any(hi):
100
+ result[hi] = polygamma(1, flat[hi] + 1.0)
101
+
102
+ if scalar:
103
+ return float(result[0])
104
+ return result.reshape(shape)
kintsugi/aggregate.py ADDED
@@ -0,0 +1,138 @@
1
+ """Null-aware count aggregation and Pearson residual normalization.
2
+
3
+ Aggregates a sparse (N x G) count matrix over an irregular partition
4
+ into a dense (K x G) residual matrix, where K << N. All operations
5
+ keep the input sparse until the final K x G product.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import scipy.sparse as sp
12
+
13
+
14
+ def aggregate_counts(
15
+ counts: sp.csr_matrix,
16
+ labels: np.ndarray,
17
+ mask: np.ndarray | None = None,
18
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
19
+ """Aggregate bin-level counts into region-level Pearson residuals.
20
+
21
+ Parameters
22
+ ----------
23
+ counts : csr_matrix, shape (N, G)
24
+ Sparse UMI count matrix. Row order must match the row-major
25
+ (C-order) flattening of the 2D lattice: bin (r, c) maps to
26
+ row ``r * C + c``.
27
+ labels : ndarray, shape (R, C), dtype int32
28
+ Region labels from ``adaptive_tessellation``. Values in
29
+ ``[0, K)``. Bins with label -1 are excluded.
30
+ mask : ndarray, shape (R, C), dtype bool, optional
31
+ If provided, only bins where mask is True are aggregated.
32
+ Bins outside the mask are ignored regardless of their label.
33
+
34
+ Returns
35
+ -------
36
+ residuals : ndarray, shape (K, G)
37
+ Pearson residuals ``(observed - expected) / sqrt(expected)``.
38
+ areas : ndarray, shape (K,)
39
+ Number of native bins per region.
40
+ depths : ndarray, shape (K,)
41
+ Total UMI per region.
42
+ centroids : ndarray, shape (K, 2)
43
+ Region centroids in (row, col) grid coordinates.
44
+ """
45
+ if labels.ndim != 2:
46
+ raise ValueError("labels must be a 2D array.")
47
+ if not sp.issparse(counts):
48
+ raise TypeError("counts must be a SciPy sparse matrix.")
49
+ if not sp.isspmatrix_csr(counts):
50
+ counts = counts.tocsr()
51
+ if counts.data.size and (
52
+ np.any(~np.isfinite(counts.data)) or np.any(counts.data < 0)
53
+ ):
54
+ raise ValueError("counts must contain only finite non-negative values.")
55
+
56
+ R, C = labels.shape
57
+ N = R * C
58
+
59
+ if counts.shape[0] != N:
60
+ raise ValueError(
61
+ f"Count matrix has {counts.shape[0]} rows but label grid has "
62
+ f"{N} bins ({R} x {C})."
63
+ )
64
+ if mask is not None:
65
+ mask = np.asarray(mask, dtype=bool)
66
+ if mask.shape != labels.shape:
67
+ raise ValueError(
68
+ f"mask has shape {mask.shape}, expected {labels.shape}."
69
+ )
70
+
71
+ flat_labels = labels.ravel() # (N,)
72
+
73
+ # Build inclusion mask.
74
+ include = flat_labels >= 0
75
+ if mask is not None:
76
+ include = include & mask.ravel()
77
+
78
+ G = counts.shape[1]
79
+
80
+ # --- Sparse indicator matrix (K x N) ----------------------------------
81
+ valid_idx = np.where(include)[0]
82
+ if valid_idx.size == 0:
83
+ empty = np.zeros((0, G), dtype=np.float64)
84
+ return (
85
+ empty,
86
+ np.zeros(0, dtype=np.float64),
87
+ np.zeros(0, dtype=np.float64),
88
+ np.zeros((0, 2), dtype=np.float64),
89
+ )
90
+
91
+ valid_labels = flat_labels[valid_idx].astype(np.int64, copy=False)
92
+ K = int(valid_labels.max()) + 1
93
+
94
+ indicator = sp.csr_matrix(
95
+ (np.ones(valid_idx.size, dtype=np.uint8), (valid_labels, valid_idx)),
96
+ shape=(K, N),
97
+ )
98
+
99
+ # --- Aggregated counts Y' = indicator @ counts (K x G) ---------------
100
+ # counts is (N, G) sparse; indicator is (K, N) sparse.
101
+ # Result is (K, G) — dense since K ~ 10^3-10^5, fits in memory.
102
+ agg_counts = (indicator @ counts).toarray().astype(np.float64, copy=False) # (K, G)
103
+
104
+ # --- Region statistics ------------------------------------------------
105
+ areas = np.bincount(valid_labels, minlength=K).astype(np.float64, copy=False)
106
+ depths = agg_counts.sum(axis=1) # (K,)
107
+ N_total = depths.sum()
108
+
109
+ # Gene totals across all included bins.
110
+ gene_totals = np.asarray(agg_counts.sum(axis=0)).ravel() # (G,)
111
+
112
+ # --- Centroids --------------------------------------------------------
113
+ row_coords, col_coords = np.divmod(valid_idx, C)
114
+ centroid_rows = np.bincount(
115
+ valid_labels,
116
+ weights=row_coords.astype(np.float64),
117
+ minlength=K,
118
+ )
119
+ centroid_cols = np.bincount(
120
+ valid_labels,
121
+ weights=col_coords.astype(np.float64),
122
+ minlength=K,
123
+ )
124
+ safe_areas = np.maximum(areas, 1.0)
125
+ centroids = np.column_stack([centroid_rows / safe_areas, centroid_cols / safe_areas])
126
+
127
+ # --- Pearson residuals ------------------------------------------------
128
+ # mu_kj = depth_k * gene_j / N_total
129
+ if N_total == 0:
130
+ return np.zeros((K, G), dtype=np.float64), areas, depths, centroids
131
+
132
+ expected = (depths[:, None] * gene_totals[None, :]) / N_total
133
+ np.subtract(agg_counts, expected, out=agg_counts)
134
+ np.maximum(expected, 1e-12, out=expected)
135
+ np.sqrt(expected, out=expected)
136
+ np.divide(agg_counts, expected, out=agg_counts)
137
+
138
+ return agg_counts, areas, depths, centroids
@@ -0,0 +1,5 @@
1
+ """Kintsugi demo — runnable via ``python -m kintsugi.demo``."""
2
+
3
+ from .._demo import main, make_toy_dataset
4
+
5
+ __all__ = ["main", "make_toy_dataset"]
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m kintsugi.demo``."""
2
+
3
+ from .._demo import main
4
+
5
+ main()
kintsugi/graph.py ADDED
@@ -0,0 +1,74 @@
1
+ """Spatial graph construction from an irregular tessellation.
2
+
3
+ Builds a region adjacency graph: two regions are connected if they share
4
+ at least one pair of rook-adjacent (4-connected) native bins.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import scipy.sparse as sp
11
+
12
+
13
+ def build_spatial_graph(
14
+ labels: np.ndarray,
15
+ ) -> sp.csr_matrix:
16
+ """Build a symmetric binary adjacency matrix from region labels.
17
+
18
+ Parameters
19
+ ----------
20
+ labels : ndarray, shape (R, C), dtype int32
21
+ Region labels from ``adaptive_tessellation``. Values in
22
+ ``[0, K)``. Bins with label -1 are ignored.
23
+
24
+ Returns
25
+ -------
26
+ adjacency : csr_matrix, shape (K, K)
27
+ Symmetric binary adjacency (no self-loops).
28
+ """
29
+ if labels.ndim != 2:
30
+ raise ValueError("labels must be a 2D array.")
31
+
32
+ R, C = labels.shape
33
+ valid_labels = labels[labels >= 0]
34
+ if valid_labels.size == 0:
35
+ return sp.csr_matrix((0, 0), dtype=np.uint8)
36
+ K = int(valid_labels.max()) + 1
37
+
38
+ edges_r: list[np.ndarray] = []
39
+ edges_c: list[np.ndarray] = []
40
+
41
+ # Horizontal neighbours: compare labels[:, :-1] with labels[:, 1:]
42
+ left = labels[:, :-1].ravel()
43
+ right = labels[:, 1:].ravel()
44
+ valid = (left >= 0) & (right >= 0) & (left != right)
45
+ edges_r.append(left[valid])
46
+ edges_c.append(right[valid])
47
+
48
+ # Vertical neighbours: compare labels[:-1, :] with labels[1:, :]
49
+ top = labels[:-1, :].ravel()
50
+ bot = labels[1:, :].ravel()
51
+ valid = (top >= 0) & (bot >= 0) & (top != bot)
52
+ edges_r.append(top[valid])
53
+ edges_c.append(bot[valid])
54
+
55
+ if not any(edge.size for edge in edges_r):
56
+ return sp.csr_matrix((K, K), dtype=np.uint8)
57
+
58
+ row = np.concatenate(edges_r)
59
+ col = np.concatenate(edges_c)
60
+
61
+ # Symmetrize.
62
+ row_sym = np.concatenate([row, col])
63
+ col_sym = np.concatenate([col, row])
64
+ data = np.ones(row_sym.size, dtype=np.uint8)
65
+
66
+ adj = sp.coo_matrix((data, (row_sym, col_sym)), shape=(K, K))
67
+ # Remove duplicates and ensure binary.
68
+ adj = adj.tocsr()
69
+ adj.data[:] = 1
70
+ # Remove any self-loops (should not exist but be safe).
71
+ adj.setdiag(0)
72
+ adj.eliminate_zeros()
73
+
74
+ return adj
@@ -0,0 +1,20 @@
1
+ """Input/output adapters for Kintsugi grid data."""
2
+
3
+ from .anndata import to_anndata
4
+ from .grid import build_regular_grid, parse_visium_barcode_coordinates
5
+ from .visium_hd import (
6
+ load_10x_feature_matrix,
7
+ load_visium_hd,
8
+ load_visium_hd_from_dir,
9
+ read_tissue_positions,
10
+ )
11
+
12
+ __all__ = [
13
+ "build_regular_grid",
14
+ "load_10x_feature_matrix",
15
+ "load_visium_hd",
16
+ "load_visium_hd_from_dir",
17
+ "parse_visium_barcode_coordinates",
18
+ "read_tissue_positions",
19
+ "to_anndata",
20
+ ]