spatialscape 0.3.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.
- spatialscape/__init__.py +13 -0
- spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
- spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
- spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
- spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
- spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
- spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
- spatialscape/_app/assets/expression-E359vKZW.js +368 -0
- spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
- spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
- spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
- spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
- spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
- spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
- spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
- spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
- spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
- spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
- spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
- spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
- spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
- spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
- spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
- spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
- spatialscape/_app/favicon.svg +161 -0
- spatialscape/_app/index.html +23 -0
- spatialscape/build.py +630 -0
- spatialscape/cli.py +399 -0
- spatialscape/config.py +570 -0
- spatialscape/convert.py +321 -0
- spatialscape/discover.py +98 -0
- spatialscape/expression.py +300 -0
- spatialscape/fingerprint.py +152 -0
- spatialscape/genes.py +251 -0
- spatialscape/geometry.py +132 -0
- spatialscape/images.py +240 -0
- spatialscape/init.py +578 -0
- spatialscape/manifest.py +162 -0
- spatialscape/obs.py +48 -0
- spatialscape/outlines.py +145 -0
- spatialscape/polygons.py +85 -0
- spatialscape/rds.py +609 -0
- spatialscape/rdsread.py +556 -0
- spatialscape/readers.py +195 -0
- spatialscape/registry.py +83 -0
- spatialscape/serve.py +262 -0
- spatialscape/session.py +474 -0
- spatialscape/site.py +274 -0
- spatialscape/synthetic.py +142 -0
- spatialscape/thumbnail.py +95 -0
- spatialscape/tracks.py +551 -0
- spatialscape/validate.py +193 -0
- spatialscape/vocab.py +188 -0
- spatialscape-0.3.0.dist-info/METADATA +68 -0
- spatialscape-0.3.0.dist-info/RECORD +58 -0
- spatialscape-0.3.0.dist-info/WHEEL +4 -0
- spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
- spatialscape-0.3.0.dist-info/licenses/LICENSE +21 -0
spatialscape/convert.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""R objects (.rds / .rda) as build inputs: converted once into a cache next to the bundle, then expanded into samples.
|
|
2
|
+
|
|
3
|
+
A `samples:` entry whose `path` is an R object is converted on the first `build` into
|
|
4
|
+
`<bundles>/.cache/<dataset id>/<key>/`, where `<key>` fingerprints the file (path, size, mtime) and the
|
|
5
|
+
conversion settings (sample_col, assay, embedding, object). The cache holds every colData column, so editing
|
|
6
|
+
`fields:` in the yaml never triggers a reconversion; a re-saved object or a changed `assay:` makes a new key.
|
|
7
|
+
|
|
8
|
+
Spatial objects (anything with spatialCoords) become one folder per sample in the SpaceRanger-like layout
|
|
9
|
+
that `images: auto` and the micron inference understand:
|
|
10
|
+
|
|
11
|
+
<key>/<sample>/adata.h5ad X = assay, obs = all colData, obsm/spatial = spatialCoords
|
|
12
|
+
<key>/<sample>/spatial/tissue_<id>_image.png from imgData (loaded rasters, or stored image files)
|
|
13
|
+
<key>/<sample>/spatial/scalefactors_json.json tissue_<id>_scalef + a spot diameter measured from the spacing
|
|
14
|
+
|
|
15
|
+
Objects without spatialCoords (snRNA-seq references) become <key>/<stem>/adata.h5ad with obsm/X_umap.
|
|
16
|
+
Either way the entry is rewritten into `glob: <key>/*/adata.h5ad` and expanded like any other glob entry.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import anndata as ad
|
|
30
|
+
import numpy as np
|
|
31
|
+
import pandas as pd
|
|
32
|
+
import scipy.sparse as sp
|
|
33
|
+
|
|
34
|
+
from .config import DatasetConfig, RObjectEntry, _merged_entry, config_from_raw
|
|
35
|
+
from .rds import RExperiment, RImage, read_experiment
|
|
36
|
+
|
|
37
|
+
Log = Callable[[str], None]
|
|
38
|
+
CONVERT_VERSION = 3 # bump when the cache layout or its contents change for the same object (3: feature names from the assay dimnames)
|
|
39
|
+
VISIUM_SPOT_UM = 55.0
|
|
40
|
+
VISIUM_SPACING_UM = 100.0
|
|
41
|
+
EMBEDDING_PREFERENCE = ("UMAP", "umap", "X_umap", "UMAP-HARMONY", "UMAP_HARMONY", "UMAP.HARMONY")
|
|
42
|
+
LAYER_ALIASES = {"logcounts": "data", "normcounts": "data"} # Bioconductor assay names -> Seurat layer names
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pick_embedding(keys) -> str | None:
|
|
46
|
+
"""The reducedDim / reduction to draw when none is named: the usual UMAP names, else anything containing
|
|
47
|
+
"umap" (a joint `wnn.umap` before a per-modality one), else t-SNE, else the first."""
|
|
48
|
+
keys = list(keys)
|
|
49
|
+
for k in EMBEDDING_PREFERENCE:
|
|
50
|
+
if k in keys:
|
|
51
|
+
return k
|
|
52
|
+
umaps = [k for k in keys if "umap" in k.lower()]
|
|
53
|
+
if umaps:
|
|
54
|
+
return next((k for k in umaps if "wnn" in k.lower()), umaps[0])
|
|
55
|
+
return next((k for k in keys if "tsne" in k.lower()), keys[0] if keys else None)
|
|
56
|
+
INDEX = "index.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class SourceStatus:
|
|
61
|
+
"""What `build` found for one R object entry."""
|
|
62
|
+
|
|
63
|
+
entry: RObjectEntry
|
|
64
|
+
key: str | None
|
|
65
|
+
cache_dir: Path | None
|
|
66
|
+
state: str # cached | converted | pending | missing
|
|
67
|
+
samples: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def label(self) -> str:
|
|
71
|
+
return {
|
|
72
|
+
"cached": f"cached ({len(self.samples)} sample{'s' if len(self.samples) != 1 else ''})",
|
|
73
|
+
"converted": f"converted ({len(self.samples)} sample{'s' if len(self.samples) != 1 else ''})",
|
|
74
|
+
"pending": "not converted yet; `build` converts it, then lists its samples",
|
|
75
|
+
"missing": "file not found",
|
|
76
|
+
}[self.state]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe(name: str) -> str:
|
|
80
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cache_key(entry: RObjectEntry) -> str:
|
|
84
|
+
"""Identity of a conversion: the file (path, size, mtime) and the settings that change its output."""
|
|
85
|
+
st = entry.path.stat()
|
|
86
|
+
parts = [str(entry.path.resolve()), st.st_size, st.st_mtime_ns, entry.sample_col, entry.assay, entry.embedding, entry.object, CONVERT_VERSION]
|
|
87
|
+
return hashlib.sha1(json.dumps(parts).encode()).hexdigest()[:16]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_index(cache_dir: Path) -> dict | None:
|
|
91
|
+
p = Path(cache_dir) / INDEX
|
|
92
|
+
if not p.exists():
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(p.read_text())
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _anndata(x: RExperiment, assay: str, rows: np.ndarray | None) -> ad.AnnData:
|
|
101
|
+
layer = assay
|
|
102
|
+
if layer not in x.assays:
|
|
103
|
+
alias = LAYER_ALIASES.get(assay)
|
|
104
|
+
if x.class_name == "Seurat" and alias in x.assays:
|
|
105
|
+
layer = alias
|
|
106
|
+
else:
|
|
107
|
+
raise KeyError(f"assay {assay!r} not found; available: {', '.join(x.assays)}")
|
|
108
|
+
mats = [x.assays[layer]]
|
|
109
|
+
var = x.var.copy()
|
|
110
|
+
for fs in x.feature_sets: # a Seurat object's other assays, as prefixed features under the genes
|
|
111
|
+
fl = layer if layer in fs.assays else "data" if "data" in fs.assays else next(iter(fs.assays))
|
|
112
|
+
mats.append(fs.assays[fl])
|
|
113
|
+
v = fs.var.copy()
|
|
114
|
+
v.index = pd.Index([f"{fs.prefix}{i}" for i in v.index.astype(str)])
|
|
115
|
+
var = pd.concat([var, v])
|
|
116
|
+
if len(mats) > 1:
|
|
117
|
+
m = sp.vstack([a if sp.issparse(a) else sp.csc_matrix(np.asarray(a)) for a in mats]).tocsc()
|
|
118
|
+
else:
|
|
119
|
+
m = mats[0]
|
|
120
|
+
if rows is not None:
|
|
121
|
+
m = m[:, rows]
|
|
122
|
+
X = m.T.tocsr() if sp.issparse(m) else np.ascontiguousarray(np.asarray(m).T)
|
|
123
|
+
obs = x.obs if rows is None else x.obs.iloc[rows]
|
|
124
|
+
for c in var.columns:
|
|
125
|
+
if var[c].dtype == object:
|
|
126
|
+
var[c] = var[c].astype("category")
|
|
127
|
+
if var.index.has_duplicates: # duplicate symbols get -1, -2 suffixes (what var_names_make_unique would do)
|
|
128
|
+
var.index = ad.utils.make_index_unique(pd.Index(var.index.astype(str)), join="-")
|
|
129
|
+
return ad.AnnData(X=X, obs=obs.copy(), var=var)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _write_image(img: RImage, dest: Path) -> bool:
|
|
133
|
+
import imageio.v3 as iio
|
|
134
|
+
|
|
135
|
+
if img.rgb is not None:
|
|
136
|
+
iio.imwrite(dest, img.rgb)
|
|
137
|
+
return True
|
|
138
|
+
if img.path and Path(img.path).exists():
|
|
139
|
+
arr = np.asarray(iio.imread(img.path))
|
|
140
|
+
if arr.ndim == 2: # grayscale: three equal planes, never a 3-pixel-wide slice
|
|
141
|
+
arr = np.stack([arr, arr, arr], axis=-1)
|
|
142
|
+
iio.imwrite(dest, arr[..., :3])
|
|
143
|
+
return True
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_spatial(x: RExperiment, out: Path, *, assay: str, sample_col: str, log: Log) -> list[str]:
|
|
148
|
+
if sample_col not in x.obs.columns:
|
|
149
|
+
raise KeyError(f"sample column {sample_col!r} not in colData; available: {', '.join(x.obs.columns)}")
|
|
150
|
+
sample_values = x.obs[sample_col].astype(str).to_numpy()
|
|
151
|
+
names: list[str] = []
|
|
152
|
+
for s in pd.unique(sample_values):
|
|
153
|
+
rows = np.flatnonzero(sample_values == s)
|
|
154
|
+
d = out / _safe(s)
|
|
155
|
+
(d / "spatial").mkdir(parents=True, exist_ok=True)
|
|
156
|
+
a = _anndata(x, assay, rows)
|
|
157
|
+
coords = x.spatial[rows]
|
|
158
|
+
a.obsm["spatial"] = np.asarray(coords, dtype=np.float64)
|
|
159
|
+
sf: dict[str, float] = {}
|
|
160
|
+
for img in [i for i in x.images if i.sample_id == s]:
|
|
161
|
+
if _write_image(img, d / "spatial" / f"tissue_{_safe(img.image_id)}_image.png"):
|
|
162
|
+
sf[f"tissue_{_safe(img.image_id)}_scalef"] = img.scale_factor
|
|
163
|
+
else:
|
|
164
|
+
log(f" warning: image {img.image_id!r} of sample {s} could not be written ({img.path or 'no pixels in the object'})")
|
|
165
|
+
finite = coords[np.isfinite(coords).all(axis=1)]
|
|
166
|
+
if len(finite) > 1: # Visium rule: neighbouring spots are 100 um apart, a spot is 55 um wide
|
|
167
|
+
from scipy.spatial import cKDTree
|
|
168
|
+
|
|
169
|
+
nn = cKDTree(finite).query(finite, k=2)[0][:, 1]
|
|
170
|
+
spacing = float(np.median(nn))
|
|
171
|
+
if spacing > 0:
|
|
172
|
+
sf["spot_diameter_fullres"] = spacing * VISIUM_SPOT_UM / VISIUM_SPACING_UM
|
|
173
|
+
else:
|
|
174
|
+
log(f" warning: {d.name}: coordinates repeat (median spot spacing 0); set `microns:` for this sample")
|
|
175
|
+
(d / "spatial" / "scalefactors_json.json").write_text(json.dumps(sf, indent=1))
|
|
176
|
+
a.write_h5ad(d / "adata.h5ad")
|
|
177
|
+
names.append(d.name)
|
|
178
|
+
log(f" {d.name}: {a.n_obs} cells, {len([k for k in sf if k.startswith('tissue_')])} image(s)")
|
|
179
|
+
ids = {i.image_id for i in x.images}
|
|
180
|
+
if ids and ids <= {"lowres"}:
|
|
181
|
+
log(" note: the object holds only the low-resolution H&E (SpaceRanger's tissue_lowres_image, about 600-1200 px wide)."
|
|
182
|
+
" For detail, point `images:` in the yaml at tissue_hires_image.png or the full-resolution scan of each sample.")
|
|
183
|
+
return names
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _write_embedding(x: RExperiment, out: Path, *, assay: str, embedding: str | None, name: str, log: Log) -> tuple[str, str]:
|
|
187
|
+
key = embedding or pick_embedding(x.reduced_dims)
|
|
188
|
+
if key is None or key not in x.reduced_dims:
|
|
189
|
+
raise ValueError(f"no 2-D embedding found; reducedDims are: {', '.join(x.reduced_dims) or 'none'} (set `embedding:` on the entry)")
|
|
190
|
+
a = _anndata(x, assay, None)
|
|
191
|
+
a.obsm["X_umap"] = np.asarray(x.reduced_dims[key][:, :2], dtype=np.float32)
|
|
192
|
+
d = out / _safe(name)
|
|
193
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
a.write_h5ad(d / "adata.h5ad")
|
|
195
|
+
log(f" {d.name}: {a.n_obs} cells, embedding {key!r} -> obsm/X_umap")
|
|
196
|
+
return d.name, key
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_cache(entry: RObjectEntry, cache_root: Path, *, log: Log = print) -> dict:
|
|
200
|
+
"""Convert one R object into `cache_root/<key>/` (written to a temporary folder first). Returns the index."""
|
|
201
|
+
key = cache_key(entry)
|
|
202
|
+
dest = Path(cache_root) / key
|
|
203
|
+
tmp = Path(cache_root) / f"{key}.tmp"
|
|
204
|
+
if tmp.exists():
|
|
205
|
+
shutil.rmtree(tmp)
|
|
206
|
+
tmp.mkdir(parents=True)
|
|
207
|
+
log(f"reading {entry.path} (the whole object is parsed; about as long as loading it in R)")
|
|
208
|
+
x = read_experiment(entry.path, object_name=entry.object)
|
|
209
|
+
spatial = x.class_name == "SpatialExperiment" and x.spatial is not None
|
|
210
|
+
what = f"{x.class_name} ({x.assay_name})" if x.assay_name else x.class_name
|
|
211
|
+
log(f" {what}: {x.n_vars} genes x {x.n_obs} cells, assays {', '.join(x.assays)}" + (f", {len(x.images)} image(s)" if x.images else ""))
|
|
212
|
+
for fs in x.feature_sets:
|
|
213
|
+
log(f" + {fs.name}: {len(fs.var):,} features as {fs.prefix!r}")
|
|
214
|
+
for note in x.notes:
|
|
215
|
+
log(f" note: {note}")
|
|
216
|
+
used_embedding = None
|
|
217
|
+
if spatial:
|
|
218
|
+
samples = _write_spatial(x, tmp, assay=entry.assay, sample_col=entry.sample_col, log=log)
|
|
219
|
+
else:
|
|
220
|
+
name, used_embedding = _write_embedding(x, tmp, assay=entry.assay, embedding=entry.embedding, name=entry.path.stem, log=log)
|
|
221
|
+
samples = [name]
|
|
222
|
+
st = entry.path.stat()
|
|
223
|
+
index = {
|
|
224
|
+
"source": str(entry.path),
|
|
225
|
+
"size": st.st_size,
|
|
226
|
+
"mtime_ns": st.st_mtime_ns,
|
|
227
|
+
"sample_col": entry.sample_col,
|
|
228
|
+
"assay": entry.assay,
|
|
229
|
+
"embedding": used_embedding,
|
|
230
|
+
"object": entry.object,
|
|
231
|
+
"class": x.class_name,
|
|
232
|
+
"spatial": spatial,
|
|
233
|
+
"assays": list(x.assays),
|
|
234
|
+
"feature_sets": [{"name": fs.name, "prefix": fs.prefix, "features": len(fs.var)} for fs in x.feature_sets],
|
|
235
|
+
"fragments": [f.path for f in x.fragments],
|
|
236
|
+
"samples": samples,
|
|
237
|
+
"cells": x.n_obs,
|
|
238
|
+
"converted": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
239
|
+
"version": CONVERT_VERSION,
|
|
240
|
+
}
|
|
241
|
+
(tmp / INDEX).write_text(json.dumps(index, indent=1))
|
|
242
|
+
if dest.exists():
|
|
243
|
+
shutil.rmtree(dest)
|
|
244
|
+
tmp.rename(dest)
|
|
245
|
+
return index
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def prune_cache(cache_root: Path, keep: set[str]) -> list[Path]:
|
|
249
|
+
"""Remove cache folders no entry references any more (old keys, leftover temporary folders)."""
|
|
250
|
+
cache_root = Path(cache_root)
|
|
251
|
+
if not cache_root.is_dir():
|
|
252
|
+
return []
|
|
253
|
+
gone: list[Path] = []
|
|
254
|
+
for p in cache_root.iterdir():
|
|
255
|
+
if p.is_dir() and p.name not in keep:
|
|
256
|
+
shutil.rmtree(p)
|
|
257
|
+
gone.append(p)
|
|
258
|
+
return gone
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _glob_entry(entry: RObjectEntry, cache_dir: Path, index: dict, raw: dict) -> dict:
|
|
262
|
+
"""The R entry rewritten as a glob over its cache folder, with defaults that depend on what the object is.
|
|
263
|
+
Those defaults sit below `defaults` / `platforms` / the entry itself, so anything the yaml says wins."""
|
|
264
|
+
e = dict(entry.template)
|
|
265
|
+
e["glob"] = str(cache_dir / "*" / "adata.h5ad")
|
|
266
|
+
|
|
267
|
+
def merged() -> dict:
|
|
268
|
+
return _merged_entry(e, raw)
|
|
269
|
+
|
|
270
|
+
if not index.get("spatial"):
|
|
271
|
+
if "platform" not in merged():
|
|
272
|
+
e["platform"] = "snrnaseq"
|
|
273
|
+
for k, v in (("kind", "embedding"), ("coords", "obsm/X_umap")):
|
|
274
|
+
if k not in merged():
|
|
275
|
+
e[k] = v
|
|
276
|
+
elif merged().get("platform") in ("xenium", "merfish") and "microns" not in merged():
|
|
277
|
+
e["microns"] = {"already_microns": True} # the cache's spot-spacing rule is for Visium only
|
|
278
|
+
return e
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def expand_r_objects(cfg: DatasetConfig, cache_root: Path, *, convert: bool, log: Log = print) -> tuple[DatasetConfig, list[SourceStatus]]:
|
|
282
|
+
"""Replace every R object entry by the samples in its cache, converting first when `convert` is set.
|
|
283
|
+
Without `convert` (a dry run) uncached objects are reported as pending and left out."""
|
|
284
|
+
if not cfg.r_objects:
|
|
285
|
+
return cfg, []
|
|
286
|
+
if cfg.path is None:
|
|
287
|
+
raise ValueError("R object entries need a config loaded from a file")
|
|
288
|
+
cache_root = Path(cache_root)
|
|
289
|
+
raw_samples: list[dict | None] = list(cfg.raw.get("samples") or [])
|
|
290
|
+
statuses: list[SourceStatus] = []
|
|
291
|
+
keep: set[str] = set()
|
|
292
|
+
for entry in cfg.r_objects:
|
|
293
|
+
if not entry.path.exists():
|
|
294
|
+
if convert:
|
|
295
|
+
raise FileNotFoundError(str(entry.path))
|
|
296
|
+
statuses.append(SourceStatus(entry, None, None, "missing"))
|
|
297
|
+
raw_samples[entry.index] = None
|
|
298
|
+
continue
|
|
299
|
+
key = cache_key(entry)
|
|
300
|
+
d = cache_root / key
|
|
301
|
+
index = read_index(d)
|
|
302
|
+
state = "cached"
|
|
303
|
+
if index is None:
|
|
304
|
+
if not convert:
|
|
305
|
+
statuses.append(SourceStatus(entry, key, d, "pending"))
|
|
306
|
+
raw_samples[entry.index] = None
|
|
307
|
+
continue
|
|
308
|
+
log(f"converting {entry.path} -> {d}")
|
|
309
|
+
index = write_cache(entry, cache_root, log=log)
|
|
310
|
+
state = "converted"
|
|
311
|
+
keep.add(key)
|
|
312
|
+
raw_samples[entry.index] = _glob_entry(entry, d, index, cfg.raw)
|
|
313
|
+
statuses.append(SourceStatus(entry, key, d, state, list(index.get("samples", []))))
|
|
314
|
+
if convert:
|
|
315
|
+
for p in prune_cache(cache_root, keep):
|
|
316
|
+
log(f"removed stale cache {p}")
|
|
317
|
+
new_raw = {**cfg.raw, "samples": [s for s in raw_samples if s is not None]}
|
|
318
|
+
new_cfg = config_from_raw(new_raw, cfg.path, original=cfg.raw)
|
|
319
|
+
new_cfg._r_objects = list(cfg.r_objects)
|
|
320
|
+
return new_cfg, statuses
|
|
321
|
+
|
spatialscape/discover.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Automatic discovery: which obs columns to expose, and which images sit next to a sample."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import anndata as ad
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from .config import FieldSpec, ImageSpec, MicronsSpec, SampleSpec
|
|
13
|
+
|
|
14
|
+
MAX_LEVELS = 200
|
|
15
|
+
SKIP_COLUMNS = re.compile(r"^(sample_id|sample|key|barcode|cell_id|cell|index|in_tissue|array_row|array_col|exclude_.*|discard.*|qc_filter|.*_1|.*_2)$", re.I)
|
|
16
|
+
NUMERIC_KEEP = re.compile(r"count|sum|detected|percent|ratio|area|score|umi|genes|mito|nucleus|transcript|total|ncount|nfeature", re.I)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def field_id(column: str) -> str:
|
|
20
|
+
fid = re.sub(r"[^A-Za-z0-9]+", "_", column).strip("_").lower()
|
|
21
|
+
return fid or "field"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def auto_fields(adata: ad.AnnData, sample: SampleSpec) -> dict[str, str]:
|
|
25
|
+
"""field id -> obs column for every categorical (2..MAX_LEVELS levels) and QC-like numeric column."""
|
|
26
|
+
out: dict[str, str] = {}
|
|
27
|
+
n = adata.n_obs
|
|
28
|
+
for col in adata.obs.columns:
|
|
29
|
+
if SKIP_COLUMNS.match(str(col)):
|
|
30
|
+
continue
|
|
31
|
+
s = adata.obs[col]
|
|
32
|
+
if isinstance(s.dtype, pd.CategoricalDtype) or s.dtype == object or str(s.dtype) in ("string", "bool", "boolean"):
|
|
33
|
+
k = s.nunique(dropna=True)
|
|
34
|
+
if 2 <= k <= MAX_LEVELS and k < n:
|
|
35
|
+
out[field_id(str(col))] = str(col)
|
|
36
|
+
elif pd.api.types.is_numeric_dtype(s) and NUMERIC_KEEP.search(str(col)):
|
|
37
|
+
out[field_id(str(col))] = str(col)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def infer_field_spec(fid: str, adata: ad.AnnData, column: str) -> FieldSpec:
|
|
42
|
+
s = adata.obs[column]
|
|
43
|
+
categorical = isinstance(s.dtype, pd.CategoricalDtype) or s.dtype == object or str(s.dtype) in ("string", "bool", "boolean")
|
|
44
|
+
name = re.sub(r"[_\.]+", " ", column).strip()
|
|
45
|
+
name = name[:1].upper() + name[1:]
|
|
46
|
+
return FieldSpec(id=fid, name=name, type="categorical" if categorical else "continuous")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def shared_image(png: Path, limit: int = 64) -> bool:
|
|
50
|
+
"""Whether sibling sample folders (`../<other>/spatial/<same name>`) hold the very same file: one slide image that
|
|
51
|
+
several sections were cut from (two Visium HD sections on one slide, say). Such an image is masked to each
|
|
52
|
+
section's own cells, or the neighbour's tissue shows up under this sample with no cells on it."""
|
|
53
|
+
try:
|
|
54
|
+
here = png.resolve()
|
|
55
|
+
root = here.parent.parent.parent
|
|
56
|
+
size = here.stat().st_size
|
|
57
|
+
mine = None
|
|
58
|
+
for other in sorted(root.iterdir())[:limit]:
|
|
59
|
+
cand = other / here.parent.name / here.name
|
|
60
|
+
if other == here.parent.parent or not cand.is_file() or cand.stat().st_size != size:
|
|
61
|
+
continue
|
|
62
|
+
if mine is None:
|
|
63
|
+
mine = hashlib.sha1(here.read_bytes()).hexdigest()
|
|
64
|
+
if hashlib.sha1(cand.read_bytes()).hexdigest() == mine:
|
|
65
|
+
return True
|
|
66
|
+
except OSError:
|
|
67
|
+
return False
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def auto_images(sample: SampleSpec) -> tuple[list[ImageSpec], MicronsSpec | None]:
|
|
72
|
+
"""Images next to the sample path. Returns (images, microns override or None)."""
|
|
73
|
+
d = Path(sample.path)
|
|
74
|
+
d = d if d.is_dir() and not (d / ".zattrs").exists() and not (d / "zarr.json").exists() else d.parent
|
|
75
|
+
if sample.kind != "spatial":
|
|
76
|
+
return [], None
|
|
77
|
+
# 1. OME-Zarr exported in the coordinate frame of the sample (level-0 pixels), e.g. image.ome.zarr
|
|
78
|
+
for cand in [d / "image.ome.zarr", *sorted(d.glob("*.ome.zarr"))]:
|
|
79
|
+
if cand.is_dir() and ((cand / ".zattrs").exists() or (cand / "zarr.json").exists()):
|
|
80
|
+
return [ImageSpec(id="he", name="H&E", path=cand, pixel_size="auto")], None
|
|
81
|
+
# 2. SpaceRanger layout: spatial/tissue_hires_image.png + scalefactors (coords are full-res pixels)
|
|
82
|
+
sp = d / "spatial"
|
|
83
|
+
sf = sp / "scalefactors_json.json"
|
|
84
|
+
if sf.exists():
|
|
85
|
+
scale = json.loads(sf.read_text())
|
|
86
|
+
# tissue_<id>_image.png with a tissue_<id>_scalef: hires first, then any other id, lowres last
|
|
87
|
+
pngs = sorted(sp.glob("tissue_*_image.png"), key=lambda q: (0 if "hires" in q.name else 2 if "lowres" in q.name else 1, q.name))
|
|
88
|
+
for png in pngs:
|
|
89
|
+
key = png.name.replace("tissue_", "tissue_", 1).replace("_image.png", "_scalef")
|
|
90
|
+
if key in scale:
|
|
91
|
+
spec = ImageSpec(id="he", name="H&E", path=png, pixel_size="auto", pixels_per_unit=float(scale[key]), mask=shared_image(png))
|
|
92
|
+
return [spec], MicronsSpec(scalefactors_json=sf)
|
|
93
|
+
if sf.exists(): # scale factors without an image (e.g. `convert` of an object whose image file was gone)
|
|
94
|
+
return [], MicronsSpec(scalefactors_json=sf)
|
|
95
|
+
# 3. Xenium morphology image
|
|
96
|
+
for tif in sorted(d.glob("morphology_focus*.ome.tif")) + sorted(d.glob("morphology*.ome.tif")):
|
|
97
|
+
return [ImageSpec(id="dapi", name="DAPI", path=tif, kind="multichannel", pixel_size="auto", pixels_per_unit=1 / 0.2125)], None
|
|
98
|
+
return [], None
|