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/rds.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Read SpatialExperiment / SingleCellExperiment / Seurat objects straight from .rds / .rda files, without R.
|
|
2
|
+
|
|
3
|
+
Parses the serialized objects with the reader in rdsread.py into plain Python structures, then picks out what the
|
|
4
|
+
viewer needs: assays (sparse or dense), colData, rowData, spatialCoords, reducedDims and imgData. For a Seurat
|
|
5
|
+
object: the layers of one assay, `meta.data`, the ident, the reductions, the other assays as prefixed feature
|
|
6
|
+
sets (ATAC peaks, gene activity, motif scores) and the fragment files a ChromatinAssay points at.
|
|
7
|
+
HDF5-backed assays (DelayedArray, BPCells) are not supported; realize them in R first.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import scipy.sparse as sp
|
|
19
|
+
|
|
20
|
+
R_NA_INT = -2147483648
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RImage:
|
|
25
|
+
sample_id: str
|
|
26
|
+
image_id: str
|
|
27
|
+
scale_factor: float
|
|
28
|
+
rgb: np.ndarray | None # (h, w, 3) uint8 for loaded images
|
|
29
|
+
path: str | None = None # for stored / remote images
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RFeatureSet:
|
|
34
|
+
"""Another assay of a Seurat object with its own features (ATAC peaks, gene activity, motif scores): drawn in
|
|
35
|
+
the viewer as features named `<prefix><feature>` next to the genes."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
prefix: str # "peak: ", "activity: ", "motif: ", or "<assay>: "
|
|
39
|
+
var: pd.DataFrame
|
|
40
|
+
assays: dict[str, Any] # layer -> features x cells
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RFragments:
|
|
45
|
+
"""A fragment file a Signac ChromatinAssay was built from (the path as it was on the machine that built it)."""
|
|
46
|
+
|
|
47
|
+
path: str
|
|
48
|
+
cells: dict[str, str] | None # cell name in the object -> barcode in the file; None when they are the same
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def run(self) -> str:
|
|
52
|
+
"""A short id for the run: the Cell Ranger sample folder (`<run>/outs/atac_fragments.tsv.gz`), else the file stem."""
|
|
53
|
+
parts = self.path.replace("\\", "/").rstrip("/").split("/")
|
|
54
|
+
if len(parts) >= 3 and parts[-2] == "outs":
|
|
55
|
+
return parts[-3]
|
|
56
|
+
name = parts[-1]
|
|
57
|
+
for suffix in (".tsv.gz", ".tsv", ".bed.gz", ".bed", ".gz"):
|
|
58
|
+
if name.endswith(suffix):
|
|
59
|
+
return name[: -len(suffix)]
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fragment_columns(cells: list[str], fragments: list[RFragments]) -> tuple[pd.Series, pd.Series] | None:
|
|
64
|
+
"""Per cell, the run and the barcode its fragments carry, from the Fragment records (None when no record maps
|
|
65
|
+
cells): the columns `tracks:` needs as `sample_column` and `barcode_column` for an object pooled from runs."""
|
|
66
|
+
if not any(f.cells for f in fragments):
|
|
67
|
+
return None
|
|
68
|
+
run = pd.Series([None] * len(cells), index=pd.Index(cells), dtype=object)
|
|
69
|
+
bc = pd.Series([None] * len(cells), index=pd.Index(cells), dtype=object)
|
|
70
|
+
for f in fragments:
|
|
71
|
+
if not f.cells:
|
|
72
|
+
continue
|
|
73
|
+
hit = run.index.intersection(list(f.cells))
|
|
74
|
+
run[hit] = f.run
|
|
75
|
+
bc[hit] = [f.cells[c] for c in hit]
|
|
76
|
+
return pd.Series(pd.Categorical(run.to_numpy()), index=run.index), bc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RExperiment:
|
|
81
|
+
class_name: str
|
|
82
|
+
assays: dict[str, Any] # name -> scipy.sparse (genes x cells, CSC) or ndarray; for Seurat the layers of one assay
|
|
83
|
+
obs: pd.DataFrame
|
|
84
|
+
var: pd.DataFrame
|
|
85
|
+
spatial: np.ndarray | None # (n_cells, 2)
|
|
86
|
+
reduced_dims: dict[str, np.ndarray] = field(default_factory=dict)
|
|
87
|
+
images: list[RImage] = field(default_factory=list)
|
|
88
|
+
feature_sets: list[RFeatureSet] = field(default_factory=list) # Seurat: the other assays
|
|
89
|
+
fragments: list[RFragments] = field(default_factory=list) # Seurat: fragment files of a ChromatinAssay
|
|
90
|
+
assay_name: str | None = None # Seurat: the assay whose layers `assays` holds
|
|
91
|
+
notes: list[str] = field(default_factory=list) # what was left out, for the build log
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def n_obs(self) -> int:
|
|
95
|
+
return len(self.obs)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def n_vars(self) -> int:
|
|
99
|
+
return len(self.var)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- helpers over the parsed structure
|
|
103
|
+
def _attrs(o: Any) -> dict:
|
|
104
|
+
return o.get("attributes") or {} if isinstance(o, dict) else {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cls(o: Any) -> str | None:
|
|
108
|
+
return o.get("class_name") if isinstance(o, dict) else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_null(o: Any) -> bool:
|
|
112
|
+
return o is None or (isinstance(o, dict) and o.get("type") == "null")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _strings(o: Any) -> list[str | None]:
|
|
116
|
+
if _is_null(o):
|
|
117
|
+
return []
|
|
118
|
+
d = o.get("data", [])
|
|
119
|
+
return [None if s is None else str(s) for s in d] # NA_character_ arrives as None; "NA" can be a real label
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _named_list(o: Any) -> dict[str, Any]:
|
|
123
|
+
"""An R list/vector with a `names` attribute -> dict."""
|
|
124
|
+
if _is_null(o):
|
|
125
|
+
return {}
|
|
126
|
+
names = _strings(_attrs(o).get("names"))
|
|
127
|
+
data = o.get("data", [])
|
|
128
|
+
return {str(n): v for n, v in zip(names, data, strict=False) if n is not None}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _dframe(o: Any) -> tuple[list[str] | None, dict[str, Any]]:
|
|
132
|
+
"""S4 DFrame -> (rownames, columns)."""
|
|
133
|
+
a = _attrs(o)
|
|
134
|
+
rn = _strings(a.get("rownames")) if not _is_null(a.get("rownames")) else None
|
|
135
|
+
return rn, _named_list(a.get("listData"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _vector_to_series(o: Any, n: int) -> pd.Series | None:
|
|
139
|
+
"""One colData column -> pandas Series (None for nested / unsupported columns)."""
|
|
140
|
+
if _is_null(o):
|
|
141
|
+
return None
|
|
142
|
+
t = o.get("type")
|
|
143
|
+
a = _attrs(o)
|
|
144
|
+
cls = _strings(a.get("class")) if "class" in a else []
|
|
145
|
+
data = o.get("data")
|
|
146
|
+
if t == "integer" and "levels" in a: # factor; NA codes arrive as NaN (or R's NA_integer_)
|
|
147
|
+
raw = np.asarray(data, dtype=np.float64)
|
|
148
|
+
na = np.isnan(raw) | (raw == R_NA_INT)
|
|
149
|
+
codes = np.where(na, 0, raw).astype(np.int64) - 1
|
|
150
|
+
levels = _strings(a["levels"])
|
|
151
|
+
return pd.Series(pd.Categorical.from_codes(codes, categories=[str(x) for x in levels]))
|
|
152
|
+
if t in ("integer", "double", "boolean"):
|
|
153
|
+
arr = np.asarray(data)
|
|
154
|
+
if arr.ndim != 1 or len(arr) != n:
|
|
155
|
+
return None
|
|
156
|
+
if t == "integer":
|
|
157
|
+
na = arr == R_NA_INT
|
|
158
|
+
if na.any(): # nullable integers: cluster ids stay integers instead of turning into 1.0, 2.0
|
|
159
|
+
return pd.Series(pd.arrays.IntegerArray(np.where(na, 0, arr).astype(np.int64), na))
|
|
160
|
+
return pd.Series(arr.astype(np.int64))
|
|
161
|
+
if t == "boolean":
|
|
162
|
+
if arr.dtype.kind == "f": # NA logicals arrive as NaN
|
|
163
|
+
return pd.Series(pd.array(np.where(np.isnan(arr), None, arr == 1), dtype="boolean"))
|
|
164
|
+
if arr.dtype == object: # or as None among bools
|
|
165
|
+
return pd.Series(pd.array([None if v is None else bool(v) for v in arr], dtype="boolean"))
|
|
166
|
+
return pd.Series(pd.array(arr.astype(bool), dtype="boolean"))
|
|
167
|
+
return pd.Series(arr)
|
|
168
|
+
if t == "string":
|
|
169
|
+
vals = _strings(o)
|
|
170
|
+
if len(vals) != n:
|
|
171
|
+
return None
|
|
172
|
+
return pd.Series(pd.Categorical(vals))
|
|
173
|
+
if "Rle" in (cls or []) or _cls(o) == "Rle":
|
|
174
|
+
return None
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _matrix(o: Any) -> Any:
|
|
179
|
+
"""dgCMatrix / dgRMatrix / dense numeric matrix -> scipy CSC (or ndarray). Raises for delayed arrays."""
|
|
180
|
+
cls = _cls(o)
|
|
181
|
+
a = _attrs(o)
|
|
182
|
+
if cls in ("dgCMatrix", "dgRMatrix", "lgCMatrix"):
|
|
183
|
+
i = np.asarray(a["i"]["data"], dtype=np.int32) if cls != "dgRMatrix" else np.asarray(a["j"]["data"], dtype=np.int32)
|
|
184
|
+
p = np.asarray(a["p"]["data"], dtype=np.int64)
|
|
185
|
+
x = np.asarray(a["x"]["data"], dtype=np.float32)
|
|
186
|
+
dim = [int(v) for v in np.asarray(a["Dim"]["data"])]
|
|
187
|
+
if cls == "dgRMatrix":
|
|
188
|
+
return sp.csr_matrix((x, i, p), shape=(dim[0], dim[1])).tocsc()
|
|
189
|
+
return sp.csc_matrix((x, i, p), shape=(dim[0], dim[1]))
|
|
190
|
+
if cls and ("Delayed" in cls or "HDF5" in cls):
|
|
191
|
+
raise ValueError(f"assay is a {cls}; realize it in R first (e.g. assay(x, name) <- as(assay(x, name), 'dgCMatrix'))")
|
|
192
|
+
if o.get("type") in ("integer", "double") and "dim" in a:
|
|
193
|
+
dim = [int(v) for v in np.asarray(a["dim"]["data"])]
|
|
194
|
+
return np.asarray(o["data"], dtype=np.float32).reshape(dim, order="F")
|
|
195
|
+
raise ValueError(f"unsupported assay class {cls or o.get('type')}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _dimnames(o: Any) -> tuple[list[str] | None, list[str] | None]:
|
|
199
|
+
a = _attrs(o)
|
|
200
|
+
dn = a.get("Dimnames") or a.get("dimnames")
|
|
201
|
+
if _is_null(dn):
|
|
202
|
+
return None, None
|
|
203
|
+
parts = dn.get("data", [])
|
|
204
|
+
out: list[list[str] | None] = []
|
|
205
|
+
for p in parts[:2]:
|
|
206
|
+
out.append(None if _is_null(p) else [str(s) for s in _strings(p)])
|
|
207
|
+
while len(out) < 2:
|
|
208
|
+
out.append(None)
|
|
209
|
+
return out[0], out[1]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _hex_to_rgb(colors, h: int, w: int) -> np.ndarray:
|
|
213
|
+
"""An R `raster` (dim = rows x columns of "#RRGGBB" strings, a numpy `U` array or a list) as an (h, w, 3) uint8
|
|
214
|
+
array, decoded without touching the strings one by one. Unlike every other R matrix, a raster is stored row by
|
|
215
|
+
row (`as.raster` transposes on the way in), so the flat vector is C order."""
|
|
216
|
+
arr = np.asarray(colors if not isinstance(colors, list) else [c if c else "#000000" for c in colors], dtype="U9")
|
|
217
|
+
n = len(arr)
|
|
218
|
+
codes = arr.view(np.uint32).reshape(n, 9) # UTF-32 code points, 0-padded
|
|
219
|
+
hexpos = np.where(codes[:, :1] == ord("#"), 1, 0) # "#RRGGBB" or "RRGGBB"
|
|
220
|
+
idx = hexpos + np.arange(6)[None, :]
|
|
221
|
+
digits = np.take_along_axis(codes, idx, axis=1).astype(np.int64)
|
|
222
|
+
val = digits - 48 # '0'..'9'
|
|
223
|
+
val = np.where(digits >= 97, digits - 87, val) # 'a'..'f'
|
|
224
|
+
val = np.where((digits >= 65) & (digits < 97), digits - 55, val) # 'A'..'F'
|
|
225
|
+
val = np.clip(val, 0, 15)
|
|
226
|
+
rgb = (val[:, 0::2] * 16 + val[:, 1::2]).astype(np.uint8) # (n, 3)
|
|
227
|
+
short = (codes[:, 6] == 0) & (hexpos[:, 0] == 0) # fewer than 6 hex digits: black, as before
|
|
228
|
+
rgb[short] = 0
|
|
229
|
+
return rgb.reshape(h, w, 3)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _images(int_metadata: dict[str, Any]) -> list[RImage]:
|
|
233
|
+
imgdata = int_metadata.get("imgData")
|
|
234
|
+
if _is_null(imgdata):
|
|
235
|
+
return []
|
|
236
|
+
_, cols = _dframe(imgdata)
|
|
237
|
+
if not cols:
|
|
238
|
+
return []
|
|
239
|
+
sample_ids = _strings(cols.get("sample_id"))
|
|
240
|
+
image_ids = _strings(cols.get("image_id"))
|
|
241
|
+
scales = list(np.asarray(cols["scaleFactor"]["data"], dtype=float)) if "scaleFactor" in cols else [1.0] * len(sample_ids)
|
|
242
|
+
data_col = cols.get("data")
|
|
243
|
+
entries = []
|
|
244
|
+
items = _attrs(data_col).get("listData", {}).get("data", []) if _cls(data_col) == "SimpleList" else data_col.get("data", []) if isinstance(data_col, dict) else []
|
|
245
|
+
for k, (sid, iid) in enumerate(zip(sample_ids, image_ids, strict=False)):
|
|
246
|
+
img = items[k] if k < len(items) else None
|
|
247
|
+
rgb = None
|
|
248
|
+
path = None
|
|
249
|
+
cls = _cls(img)
|
|
250
|
+
a = _attrs(img)
|
|
251
|
+
if cls == "LoadedSpatialImage":
|
|
252
|
+
raster = a.get("image")
|
|
253
|
+
dim = [int(v) for v in np.asarray(_attrs(raster)["dim"]["data"])] if "dim" in _attrs(raster) else None
|
|
254
|
+
if dim:
|
|
255
|
+
rgb = _hex_to_rgb(raster.get("data", []), dim[0], dim[1])
|
|
256
|
+
elif cls == "StoredSpatialImage":
|
|
257
|
+
path = (_strings(a.get("path")) or [None])[0]
|
|
258
|
+
elif cls == "RemoteSpatialImage":
|
|
259
|
+
path = (_strings(a.get("url")) or [None])[0]
|
|
260
|
+
entries.append(RImage(sample_id=str(sid), image_id=str(iid), scale_factor=float(scales[k]), rgb=rgb, path=path))
|
|
261
|
+
return entries
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------- parsing (our own reader, see rdsread.py)
|
|
265
|
+
# Slots nobody here needs and that are large in Seurat objects: walked past without being built.
|
|
266
|
+
SKIP_SLOTS = frozenset({"scale.data", "graphs", "neighbors", "commands", "tools", "feature.loadings", "feature.loadings.projected", "jackstraw", "misc"})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parse(path: Path, object_name: str | None):
|
|
270
|
+
"""Parse an .rds (one object) or .rda / .RData (named objects -> dict) file."""
|
|
271
|
+
from .rdsread import read_rda, read_rds
|
|
272
|
+
|
|
273
|
+
if path.suffix.lower() in (".rda", ".rdata"):
|
|
274
|
+
objs = read_rda(path, skip=SKIP_SLOTS, only=object_name)
|
|
275
|
+
if object_name is not None and object_name not in objs:
|
|
276
|
+
raise KeyError(f"{path}: no object named {object_name!r}; found {list(read_rda(path, skip=SKIP_SLOTS))}")
|
|
277
|
+
return objs
|
|
278
|
+
return read_rds(path, skip=SKIP_SLOTS)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------- Seurat objects
|
|
282
|
+
|
|
283
|
+
PEAK_NAME = re.compile(r"^(chr)?[0-9A-Za-z_.]+[:-]\d+-\d+$") # chr1-100-200 (Signac) or chr1:100-200
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _r_dataframe(o: Any) -> tuple[list[str] | None, dict[str, Any]]:
|
|
287
|
+
"""S3 data.frame (Seurat meta.data, meta.features) -> (rownames, columns). Compact row names c(NA, -n) mean 1..n."""
|
|
288
|
+
if _is_null(o):
|
|
289
|
+
return None, {}
|
|
290
|
+
a = _attrs(o)
|
|
291
|
+
rn = a.get("row.names")
|
|
292
|
+
rownames = None
|
|
293
|
+
if not _is_null(rn):
|
|
294
|
+
if rn.get("type") == "string":
|
|
295
|
+
rownames = [str(x) for x in _strings(rn)]
|
|
296
|
+
else:
|
|
297
|
+
vals = np.asarray(rn.get("data", []), dtype=np.float64)
|
|
298
|
+
if len(vals) == 2 and (np.isnan(vals[0]) or vals[0] == R_NA_INT):
|
|
299
|
+
rownames = [str(i) for i in range(1, int(abs(vals[1])) + 1)]
|
|
300
|
+
else:
|
|
301
|
+
rownames = [str(int(v)) for v in vals]
|
|
302
|
+
return rownames, _named_list(o)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _dense(o: Any) -> np.ndarray | None:
|
|
306
|
+
"""A numeric R matrix (double/integer with `dim`) as a float64 array, or None for anything else / empty."""
|
|
307
|
+
if _is_null(o) or o.get("type") not in ("double", "integer") or "dim" not in _attrs(o):
|
|
308
|
+
return None
|
|
309
|
+
dim = [int(v) for v in np.asarray(_attrs(o)["dim"]["data"])]
|
|
310
|
+
if not all(dim):
|
|
311
|
+
return None
|
|
312
|
+
return np.asarray(o["data"], dtype=np.float64).reshape(dim, order="F")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _logmap(o: Any) -> tuple[list[str], dict[str, np.ndarray]]:
|
|
316
|
+
"""Seurat's LogMap (a logical matrix, rows x layers) -> (row names, {layer: mask})."""
|
|
317
|
+
a = _attrs(o)
|
|
318
|
+
dim = [int(v) for v in np.asarray(a["dim"]["data"])]
|
|
319
|
+
rows, cols = _dimnames(o)
|
|
320
|
+
data = np.asarray(o.get("data", []))
|
|
321
|
+
mat = (np.where(np.isnan(data), 0, data) if data.dtype.kind == "f" else data).reshape(dim, order="F").astype(bool)
|
|
322
|
+
return [str(r) for r in (rows or [])], {str(c): mat[:, j] for j, c in enumerate(cols or [])}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _nonempty(m: Any) -> bool:
|
|
326
|
+
if _is_null(m):
|
|
327
|
+
return False
|
|
328
|
+
a = _attrs(m)
|
|
329
|
+
dim = a.get("Dim") or a.get("dim")
|
|
330
|
+
return not _is_null(dim) and all(int(v) for v in np.asarray(dim["data"]))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _layer_matrix(m: Any) -> Any:
|
|
334
|
+
"""A layer (dgCMatrix or dense) as features x cells CSC."""
|
|
335
|
+
out = _matrix(m)
|
|
336
|
+
return out if sp.issparse(out) else sp.csc_matrix(out)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _align_cells(mat: Any, have: list[str] | None, want: list[str], what: str) -> Any:
|
|
340
|
+
"""Reorder the columns of a features x cells matrix from `have` to `want` (the object's cells)."""
|
|
341
|
+
if have is None or list(have) == list(want):
|
|
342
|
+
if mat.shape[1] != len(want):
|
|
343
|
+
raise ValueError(f"{what}: {mat.shape[1]} cells, but the object has {len(want)}")
|
|
344
|
+
return mat
|
|
345
|
+
pos = {c: i for i, c in enumerate(have)}
|
|
346
|
+
try:
|
|
347
|
+
idx = np.fromiter((pos[c] for c in want), dtype=np.int64, count=len(want))
|
|
348
|
+
except KeyError as e:
|
|
349
|
+
raise ValueError(f"{what}: cell {e.args[0]!r} of the object is missing from the assay") from None
|
|
350
|
+
return mat[:, idx]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _seurat_assay(obj: Any, name: str, cells: list[str]) -> tuple[list[str], dict[str, Any], pd.DataFrame]:
|
|
354
|
+
"""One Seurat assay -> (features, {layer: features x cells CSC in the object's cell order}, feature data frame).
|
|
355
|
+
`Assay` / `ChromatinAssay` / `SCTAssay` keep `counts` and `data`; `Assay5` joins split layers (`counts.run1`,
|
|
356
|
+
`counts.run2`, ...) through its LogMaps. `scale.data` is never read."""
|
|
357
|
+
a = _attrs(obj)
|
|
358
|
+
cls = _cls(obj) or ""
|
|
359
|
+
layers: dict[str, Any] = {}
|
|
360
|
+
if cls == "Assay5":
|
|
361
|
+
feat_names, feat_masks = _logmap(a["features"])
|
|
362
|
+
cell_names, cell_masks = _logmap(a["cells"])
|
|
363
|
+
groups: dict[str, list[str]] = {}
|
|
364
|
+
for lname in _named_list(a["layers"]):
|
|
365
|
+
base = lname.split(".")[0]
|
|
366
|
+
if base == "scale":
|
|
367
|
+
continue
|
|
368
|
+
groups.setdefault(base, []).append(lname)
|
|
369
|
+
pieces = _named_list(a["layers"])
|
|
370
|
+
F, C = len(feat_names), len(cell_names)
|
|
371
|
+
for base, names in groups.items():
|
|
372
|
+
if len(names) == 1 and feat_masks[names[0]].all() and cell_masks[names[0]].all():
|
|
373
|
+
m = _layer_matrix(pieces[names[0]])
|
|
374
|
+
if m.shape != (F, C):
|
|
375
|
+
raise ValueError(f"assay {name}: layer {names[0]} is {m.shape}, expected {(F, C)}")
|
|
376
|
+
layers[base] = m
|
|
377
|
+
continue
|
|
378
|
+
rows_all, cols_all, vals_all = [], [], []
|
|
379
|
+
for lname in names:
|
|
380
|
+
m = _layer_matrix(pieces[lname]).tocoo()
|
|
381
|
+
fi = np.flatnonzero(feat_masks[lname])
|
|
382
|
+
ci = np.flatnonzero(cell_masks[lname])
|
|
383
|
+
if m.shape != (len(fi), len(ci)):
|
|
384
|
+
raise ValueError(f"assay {name}: layer {lname} is {m.shape}, but its maps cover {(len(fi), len(ci))} (join the layers in R with JoinLayers())")
|
|
385
|
+
rows_all.append(fi[m.row])
|
|
386
|
+
cols_all.append(ci[m.col])
|
|
387
|
+
vals_all.append(m.data)
|
|
388
|
+
layers[base] = sp.csc_matrix((np.concatenate(vals_all), (np.concatenate(rows_all), np.concatenate(cols_all))), shape=(F, C))
|
|
389
|
+
have = cell_names
|
|
390
|
+
features = feat_names
|
|
391
|
+
_, fcols = _r_dataframe(a.get("meta.data"))
|
|
392
|
+
else: # Assay, ChromatinAssay, SCTAssay
|
|
393
|
+
have = None
|
|
394
|
+
features = None
|
|
395
|
+
for lname in ("counts", "data"):
|
|
396
|
+
m = a.get(lname)
|
|
397
|
+
if _nonempty(m):
|
|
398
|
+
layers[lname] = _layer_matrix(m)
|
|
399
|
+
if features is None:
|
|
400
|
+
features, have = _dimnames(m)
|
|
401
|
+
if features is None:
|
|
402
|
+
rn, _ = _r_dataframe(a.get("meta.features"))
|
|
403
|
+
features = rn
|
|
404
|
+
_, fcols = _r_dataframe(a.get("meta.features"))
|
|
405
|
+
if not layers:
|
|
406
|
+
raise ValueError(f"assay {name}: no counts or data layer")
|
|
407
|
+
n_feat = next(iter(layers.values())).shape[0]
|
|
408
|
+
if features is None or len(features) != n_feat:
|
|
409
|
+
features = [f"{name}_{i}" for i in range(n_feat)]
|
|
410
|
+
layers = {k: _align_cells(v, have, cells, f"assay {name}, layer {k}") for k, v in layers.items()}
|
|
411
|
+
var = pd.DataFrame(index=pd.Index([str(f) for f in features]))
|
|
412
|
+
for cname, col in fcols.items():
|
|
413
|
+
s = _vector_to_series(col, n_feat)
|
|
414
|
+
if s is not None:
|
|
415
|
+
var[cname] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.astype(str).to_numpy()
|
|
416
|
+
return [str(f) for f in features], layers, var
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _peak_like(features: list[str]) -> bool:
|
|
420
|
+
sample = features[: min(len(features), 500)]
|
|
421
|
+
return bool(sample) and sum(1 for f in sample if PEAK_NAME.match(f)) >= 0.9 * len(sample)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _feature_prefix(name: str, cls: str, features: list[str]) -> str:
|
|
425
|
+
low = name.lower()
|
|
426
|
+
if cls == "ChromatinAssay" or _peak_like(features):
|
|
427
|
+
return "peak: "
|
|
428
|
+
if "activ" in low:
|
|
429
|
+
return "activity: "
|
|
430
|
+
if "chromvar" in low or "motif" in low:
|
|
431
|
+
return "motif: "
|
|
432
|
+
if low in ("adt", "protein", "cite"):
|
|
433
|
+
return "protein: "
|
|
434
|
+
return f"{name}: "
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _fragments_of(obj: Any) -> list[RFragments]:
|
|
438
|
+
out = []
|
|
439
|
+
for fr in _attrs(obj).get("fragments", {}).get("data", []) if not _is_null(_attrs(obj).get("fragments")) else []:
|
|
440
|
+
fa = _attrs(fr)
|
|
441
|
+
path = (_strings(fa.get("path")) or [None])[0]
|
|
442
|
+
if not path:
|
|
443
|
+
continue
|
|
444
|
+
cells_obj = fa.get("cells")
|
|
445
|
+
cells = None
|
|
446
|
+
if not _is_null(cells_obj) and cells_obj.get("type") == "string":
|
|
447
|
+
names = _strings(_attrs(cells_obj).get("names"))
|
|
448
|
+
vals = _strings(cells_obj)
|
|
449
|
+
if names and len(names) == len(vals):
|
|
450
|
+
cells = {str(n): str(v) for n, v in zip(names, vals, strict=False) if n is not None and v is not None}
|
|
451
|
+
out.append(RFragments(path=path, cells=cells))
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _read_seurat(root: Any, path: Path) -> RExperiment:
|
|
456
|
+
a = _attrs(root)
|
|
457
|
+
cells, cols = _r_dataframe(a.get("meta.data"))
|
|
458
|
+
if not cells:
|
|
459
|
+
raise ValueError(f"{path}: Seurat object without cell names in meta.data")
|
|
460
|
+
n = len(cells)
|
|
461
|
+
obs = pd.DataFrame(index=pd.Index(cells))
|
|
462
|
+
for name, col in cols.items():
|
|
463
|
+
s = _vector_to_series(col, n)
|
|
464
|
+
if s is not None:
|
|
465
|
+
obs[name] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.values
|
|
466
|
+
ident = a.get("active.ident")
|
|
467
|
+
if not _is_null(ident) and "ident" not in obs:
|
|
468
|
+
s = _vector_to_series(ident, n)
|
|
469
|
+
names = _strings(_attrs(ident).get("names"))
|
|
470
|
+
if s is not None:
|
|
471
|
+
if names and len(names) == n and list(names) != cells:
|
|
472
|
+
s = s.set_axis(names).reindex(cells)
|
|
473
|
+
obs["ident"] = s.values
|
|
474
|
+
|
|
475
|
+
assays = _named_list(a.get("assays"))
|
|
476
|
+
if not assays:
|
|
477
|
+
raise ValueError(f"{path}: Seurat object without assays")
|
|
478
|
+
parsed = {name: (_cls(obj) or "", *_seurat_assay(obj, name, cells)) for name, obj in assays.items()}
|
|
479
|
+
active = (_strings(a.get("active.assay")) or [None])[0]
|
|
480
|
+
gene_like = [nm for nm, (cls, feats, _, _) in parsed.items() if cls != "ChromatinAssay" and not _peak_like(feats)]
|
|
481
|
+
primary = active if active in gene_like else (gene_like[0] if gene_like else next(iter(parsed)))
|
|
482
|
+
_pcls, pfeat, players, pvar = parsed[primary]
|
|
483
|
+
notes: list[str] = []
|
|
484
|
+
sets: list[RFeatureSet] = []
|
|
485
|
+
primary_set = set(pfeat)
|
|
486
|
+
for nm, (cls, feats, layers, var) in parsed.items():
|
|
487
|
+
if nm == primary:
|
|
488
|
+
continue
|
|
489
|
+
shared = sum(1 for f in feats[:2000] if f in primary_set)
|
|
490
|
+
if feats and shared >= 0.5 * min(len(feats), 2000):
|
|
491
|
+
notes.append(f"assay {nm} left out: its features are the {primary} genes again")
|
|
492
|
+
continue
|
|
493
|
+
sets.append(RFeatureSet(name=nm, prefix=_feature_prefix(nm, cls, feats), var=var, assays=layers))
|
|
494
|
+
fragments = [f for nm, obj in assays.items() if _cls(obj) == "ChromatinAssay" for f in _fragments_of(obj)]
|
|
495
|
+
fcols = fragment_columns(cells, fragments)
|
|
496
|
+
if fcols is not None:
|
|
497
|
+
obs["fragment_run"], obs["fragment_barcode"] = fcols[0].to_numpy(), fcols[1].to_numpy()
|
|
498
|
+
|
|
499
|
+
reduced: dict[str, np.ndarray] = {}
|
|
500
|
+
for name, dr in _named_list(a.get("reductions")).items():
|
|
501
|
+
emb = _dense(_attrs(dr).get("cell.embeddings"))
|
|
502
|
+
if emb is None or emb.shape[0] != n:
|
|
503
|
+
continue
|
|
504
|
+
rows, _ = _dimnames(_attrs(dr).get("cell.embeddings"))
|
|
505
|
+
if rows and list(rows) != cells:
|
|
506
|
+
pos = {c: i for i, c in enumerate(rows)}
|
|
507
|
+
emb = emb[[pos[c] for c in cells]] if all(c in pos for c in cells) else emb
|
|
508
|
+
reduced[name] = emb
|
|
509
|
+
if not _is_null(a.get("images")) and a["images"].get("data"):
|
|
510
|
+
notes.append("Seurat spatial images are not read yet; export a SpatialExperiment for spatial samples")
|
|
511
|
+
return RExperiment(
|
|
512
|
+
class_name="Seurat", assays=players, obs=obs, var=pvar, spatial=None, reduced_dims=reduced, images=[],
|
|
513
|
+
feature_sets=sets, fragments=fragments, assay_name=primary, notes=notes,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ---------------------------------------------------------------- public API
|
|
518
|
+
def read_experiment(path: str | Path, object_name: str | None = None) -> RExperiment:
|
|
519
|
+
"""Parse a SpatialExperiment / SingleCellExperiment / SummarizedExperiment / Seurat object from .rds or .rda/.RData."""
|
|
520
|
+
path = Path(path)
|
|
521
|
+
if path.suffix.lower() in (".rda", ".rdata"):
|
|
522
|
+
objs = _parse(path, object_name)
|
|
523
|
+
if not isinstance(objs, dict):
|
|
524
|
+
raise ValueError(f"{path}: could not read the .rda file")
|
|
525
|
+
candidates = {k: v for k, v in objs.items() if _cls(v) in SUPPORTED_CLASSES}
|
|
526
|
+
if object_name:
|
|
527
|
+
if object_name not in objs:
|
|
528
|
+
raise KeyError(f"{path}: no object named {object_name!r}; found {list(objs)}")
|
|
529
|
+
root = objs[object_name]
|
|
530
|
+
elif len(candidates) == 1:
|
|
531
|
+
root = next(iter(candidates.values()))
|
|
532
|
+
else:
|
|
533
|
+
raise ValueError(f"{path}: contains {list(objs)}; pick one with --object")
|
|
534
|
+
else:
|
|
535
|
+
root = _parse(path, None)
|
|
536
|
+
cls = _cls(root)
|
|
537
|
+
if cls == "Seurat":
|
|
538
|
+
return _read_seurat(root, path)
|
|
539
|
+
if cls not in SUPPORTED_CLASSES:
|
|
540
|
+
raise ValueError(f"{path}: expected a SpatialExperiment / SingleCellExperiment / Seurat object, got {cls}")
|
|
541
|
+
a = _attrs(root)
|
|
542
|
+
|
|
543
|
+
# assays
|
|
544
|
+
assays_list = _attrs(_attrs(a["assays"]).get("data")).get("listData")
|
|
545
|
+
assays = {name: _matrix(m) for name, m in _named_list(assays_list).items()}
|
|
546
|
+
if not assays:
|
|
547
|
+
raise ValueError(f"{path}: no assays")
|
|
548
|
+
first = next(iter(assays.values()))
|
|
549
|
+
n_vars, n_obs = first.shape
|
|
550
|
+
|
|
551
|
+
# names: rowRanges partitioning NAMES (RangedSE) or NAMES slot; colData rownames; else Dimnames
|
|
552
|
+
rownames = None
|
|
553
|
+
rr = a.get("rowRanges")
|
|
554
|
+
if not _is_null(rr):
|
|
555
|
+
part = _attrs(rr).get("partitioning")
|
|
556
|
+
if not _is_null(part) and not _is_null(_attrs(part).get("NAMES")):
|
|
557
|
+
rownames = _strings(_attrs(part)["NAMES"])
|
|
558
|
+
if rownames is None and not _is_null(a.get("NAMES")):
|
|
559
|
+
rownames = _strings(a["NAMES"])
|
|
560
|
+
col_rn, col_cols = _dframe(a["colData"])
|
|
561
|
+
dn_rows, dn_cols = _dimnames(_named_list(assays_list)[next(iter(assays))])
|
|
562
|
+
if rownames is None or len(rownames) != n_vars:
|
|
563
|
+
rownames = dn_rows
|
|
564
|
+
colnames = col_rn if col_rn else dn_cols
|
|
565
|
+
if rownames is None or len(rownames) != n_vars:
|
|
566
|
+
rownames = [f"gene{i}" for i in range(n_vars)]
|
|
567
|
+
if colnames is None or len(colnames) != n_obs:
|
|
568
|
+
colnames = [f"cell{i}" for i in range(n_obs)]
|
|
569
|
+
|
|
570
|
+
# colData
|
|
571
|
+
obs = pd.DataFrame(index=pd.Index([str(c) for c in colnames], name=None))
|
|
572
|
+
for name, col in col_cols.items():
|
|
573
|
+
s = _vector_to_series(col, n_obs)
|
|
574
|
+
if s is not None:
|
|
575
|
+
obs[name] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.values
|
|
576
|
+
# rowData: elementMetadata of rowRanges (RangedSE) or of the object
|
|
577
|
+
var = pd.DataFrame(index=pd.Index([str(r) for r in rownames]))
|
|
578
|
+
em = _attrs(rr).get("elementMetadata") if not _is_null(rr) else None
|
|
579
|
+
if _is_null(em):
|
|
580
|
+
em = a.get("elementMetadata")
|
|
581
|
+
if not _is_null(em):
|
|
582
|
+
_, rcols = _dframe(em)
|
|
583
|
+
for name, col in rcols.items():
|
|
584
|
+
s = _vector_to_series(col, n_vars)
|
|
585
|
+
if s is not None:
|
|
586
|
+
var[name] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.astype(str).to_numpy()
|
|
587
|
+
|
|
588
|
+
# int_colData: reducedDims, spatialCoords
|
|
589
|
+
spatial = None
|
|
590
|
+
reduced: dict[str, np.ndarray] = {}
|
|
591
|
+
icd = a.get("int_colData")
|
|
592
|
+
if not _is_null(icd):
|
|
593
|
+
_, icols = _dframe(icd)
|
|
594
|
+
sc = icols.get("spatialCoords")
|
|
595
|
+
if not _is_null(sc) and "dim" in _attrs(sc):
|
|
596
|
+
dim = [int(v) for v in np.asarray(_attrs(sc)["dim"]["data"])]
|
|
597
|
+
spatial = np.asarray(sc["data"], dtype=np.float64).reshape(dim, order="F")[:, :2]
|
|
598
|
+
rd = icols.get("reducedDims")
|
|
599
|
+
if not _is_null(rd):
|
|
600
|
+
_, rcols = _dframe(rd)
|
|
601
|
+
for name, m in rcols.items():
|
|
602
|
+
if isinstance(m, dict) and "dim" in _attrs(m) and m.get("type") in ("double", "integer"):
|
|
603
|
+
dim = [int(v) for v in np.asarray(_attrs(m)["dim"]["data"])]
|
|
604
|
+
reduced[name] = np.asarray(m["data"], dtype=np.float64).reshape(dim, order="F")
|
|
605
|
+
images = _images(_named_list(a.get("int_metadata"))) if not _is_null(a.get("int_metadata")) else []
|
|
606
|
+
return RExperiment(class_name=cls or "", assays=assays, obs=obs, var=var, spatial=spatial, reduced_dims=reduced, images=images)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
SUPPORTED_CLASSES = {"SpatialExperiment", "SingleCellExperiment", "SummarizedExperiment", "RangedSummarizedExperiment", "Seurat"}
|