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/session.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""`spatialscape.show(...)`: look at an object from the session you are in, no site needed.
|
|
2
|
+
|
|
3
|
+
import spatialscape
|
|
4
|
+
s = spatialscape.show(adata, color="cell_type") # builds once, starts a local server, shows the viewer
|
|
5
|
+
cells = s.selection() # the cells inside the last lasso, as obs names
|
|
6
|
+
sub = adata[cells] # straight into a DE test
|
|
7
|
+
|
|
8
|
+
Accepts an AnnData in memory, a path (h5ad, zarr, SpatialData, .rds / .rda), a dataset yaml or a built bundle.
|
|
9
|
+
An in-memory object is built straight from memory into ~/.cache/spatialscape/show/<hash>, where the hash covers
|
|
10
|
+
the data and the settings, so showing the same object again is instant. One server thread per Python process
|
|
11
|
+
serves every bundle shown so far (the gallery lists them); the viewer posts each lasso selection back to it, and
|
|
12
|
+
`selection()` maps the selection onto the rows of your object.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import contextlib
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import urllib.request
|
|
26
|
+
import webbrowser
|
|
27
|
+
from collections.abc import Callable, Iterator
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
Log = Callable[[str], None]
|
|
35
|
+
EMBEDDING_RE = re.compile(r"umap|tsne|t-sne|embedding|draw_graph|pca", re.I)
|
|
36
|
+
|
|
37
|
+
_SERVER: dict[str, Any] = {} # one per process: httpd, thread, url, root
|
|
38
|
+
_LOCK = threading.Lock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cache_root() -> Path:
|
|
42
|
+
from .genes import cache_dir
|
|
43
|
+
|
|
44
|
+
return cache_dir() / "show"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------- what to show
|
|
48
|
+
def _safe(name: str) -> str:
|
|
49
|
+
s = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name)).strip("_")
|
|
50
|
+
return s or "sample"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _hash_bytes(h, x) -> None:
|
|
54
|
+
if isinstance(x, np.ndarray):
|
|
55
|
+
h.update(np.ascontiguousarray(x).tobytes())
|
|
56
|
+
else:
|
|
57
|
+
h.update(str(x).encode())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _hash_adata(adata, params: dict, *, obs_columns: list[str], obsm_key: str | None, layer: str) -> str:
|
|
61
|
+
"""A digest of the data that matters for the viewer: names, the used obs columns, coordinates and a strided
|
|
62
|
+
sample of the matrix. Cheap enough to run on every call; strict enough that an edited object rebuilds."""
|
|
63
|
+
h = hashlib.sha1()
|
|
64
|
+
_hash_bytes(h, json.dumps(params, sort_keys=True, default=str))
|
|
65
|
+
_hash_bytes(h, adata.shape)
|
|
66
|
+
if getattr(adata, "isbacked", False): # on disk: the file identity stands in for the matrix
|
|
67
|
+
p = Path(str(adata.filename))
|
|
68
|
+
st = p.stat() if p.exists() else None
|
|
69
|
+
_hash_bytes(h, f"backed:{p}:{st.st_size if st else 0}:{st.st_mtime_ns if st else 0}")
|
|
70
|
+
_hash_bytes(h, pd.util.hash_pandas_object(pd.Series(np.asarray(adata.obs_names, dtype=str)), index=False).to_numpy())
|
|
71
|
+
_hash_bytes(h, pd.util.hash_pandas_object(pd.Series(np.asarray(adata.var_names, dtype=str)), index=False).to_numpy())
|
|
72
|
+
for col in obs_columns:
|
|
73
|
+
if col in adata.obs:
|
|
74
|
+
_hash_bytes(h, pd.util.hash_pandas_object(adata.obs[col].astype(str), index=False).to_numpy())
|
|
75
|
+
if obsm_key is not None and obsm_key in adata.obsm:
|
|
76
|
+
_hash_bytes(h, np.asarray(adata.obsm[obsm_key])[:, :2].astype(np.float64))
|
|
77
|
+
if getattr(adata, "isbacked", False):
|
|
78
|
+
return h.hexdigest()[:16]
|
|
79
|
+
X = adata.X if layer == "X" else adata.layers[layer]
|
|
80
|
+
if hasattr(X, "nnz") and hasattr(X, "data"):
|
|
81
|
+
_hash_bytes(h, X.nnz)
|
|
82
|
+
_hash_bytes(h, np.asarray(X.data[:: max(1, X.nnz // 200_000)]))
|
|
83
|
+
if hasattr(X, "indptr"):
|
|
84
|
+
_hash_bytes(h, np.asarray(X.indptr[:: max(1, len(X.indptr) // 50_000)]))
|
|
85
|
+
else: # dense (or array-like): a strided sample of rows, never the whole matrix
|
|
86
|
+
step = max(1, adata.n_obs // 500)
|
|
87
|
+
arr = np.asarray(X[::step])
|
|
88
|
+
_hash_bytes(h, arr.ravel()[:: max(1, arr.size // 200_000)])
|
|
89
|
+
return h.hexdigest()[:16]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _coords_for(adata, coords: str | None) -> str:
|
|
93
|
+
if coords is not None:
|
|
94
|
+
return coords if coords.startswith(("obsm/", "obs/")) else f"obsm/{coords}"
|
|
95
|
+
if "spatial" in adata.obsm:
|
|
96
|
+
return "obsm/spatial"
|
|
97
|
+
emb = next((k for k in adata.obsm if EMBEDDING_RE.search(str(k))), None)
|
|
98
|
+
if emb is None:
|
|
99
|
+
raise ValueError(f"no coordinates found: pass coords='obsm/<key>' (obsm has {list(adata.obsm.keys())})")
|
|
100
|
+
return f"obsm/{emb}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _config_for_adata(adata, *, key: str, name: str, coords: str, platform: str | None, sample: str | None, fields, color: str | None, layer: str, normalized: str):
|
|
104
|
+
"""A DatasetConfig for one in-memory object: `init`'s column heuristics, one sample per value of `sample`."""
|
|
105
|
+
from .config import DatasetConfig, ExpressionSpec, SampleSpec, SubsetSpec
|
|
106
|
+
from .discover import field_id
|
|
107
|
+
from .init import describe_columns, suggest_fields
|
|
108
|
+
|
|
109
|
+
obs = adata.obs
|
|
110
|
+
spatial = "spatial" in coords.lower()
|
|
111
|
+
if platform is None:
|
|
112
|
+
platform = ("visium" if "spatial" in (adata.uns or {}) else "other") if spatial else "snrnaseq"
|
|
113
|
+
fmap: Any
|
|
114
|
+
if fields == "auto":
|
|
115
|
+
fmap = "auto"
|
|
116
|
+
elif isinstance(fields, dict):
|
|
117
|
+
fmap = dict(fields)
|
|
118
|
+
else:
|
|
119
|
+
fmap = dict(suggest_fields(describe_columns(obs), skip={sample} if sample else None).mapped)
|
|
120
|
+
for col in fields or []:
|
|
121
|
+
if col not in obs:
|
|
122
|
+
raise KeyError(f"fields: obs column {col!r} not found")
|
|
123
|
+
fmap[field_id(col)] = col
|
|
124
|
+
default_color: dict | None = None
|
|
125
|
+
default_gene: str | None = None
|
|
126
|
+
if color is not None:
|
|
127
|
+
if color in obs.columns:
|
|
128
|
+
if fmap != "auto" and color not in fmap.values():
|
|
129
|
+
fmap[field_id(color)] = color
|
|
130
|
+
default_color = {"field": field_id(color) if fmap == "auto" else next(f for f, c in fmap.items() if c == color)}
|
|
131
|
+
elif color in adata.var_names:
|
|
132
|
+
default_gene = color
|
|
133
|
+
else:
|
|
134
|
+
raise KeyError(f"color={color!r} is neither an obs column nor a gene")
|
|
135
|
+
elif fmap != "auto" and fmap:
|
|
136
|
+
first_cat = next((f for f, c in fmap.items() if isinstance(c, str) and not pd.api.types.is_numeric_dtype(obs[c])), None)
|
|
137
|
+
if first_cat:
|
|
138
|
+
default_color = {"field": first_cat}
|
|
139
|
+
base: dict[str, Any] = {"platform": platform, "kind": "spatial" if spatial else "embedding", "path": Path(f"memory:{key}"), "coords": coords, "expression": ExpressionSpec(layer=layer, normalized=normalized), "fields": fmap, "images": []}
|
|
140
|
+
specs: list[SampleSpec] = []
|
|
141
|
+
rows: dict[str, np.ndarray] = {}
|
|
142
|
+
if sample is None:
|
|
143
|
+
specs.append(SampleSpec(id="all", name=name, **base))
|
|
144
|
+
rows["all"] = np.arange(adata.n_obs)
|
|
145
|
+
else:
|
|
146
|
+
if sample not in obs:
|
|
147
|
+
raise KeyError(f"sample={sample!r} is not an obs column")
|
|
148
|
+
values = obs[sample].astype(str).to_numpy()
|
|
149
|
+
for v in pd.unique(values):
|
|
150
|
+
sid = _safe(v)
|
|
151
|
+
specs.append(SampleSpec(id=sid, name=str(v), group=str(v), subset=SubsetSpec(column=sample, values=[str(v)]), **base))
|
|
152
|
+
rows[sid] = np.flatnonzero(values == v)
|
|
153
|
+
cfg = DatasetConfig(id=key, name=name, default_color=default_color, default_gene=default_gene, samples=specs)
|
|
154
|
+
return cfg, rows
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------- one builder per bundle folder
|
|
158
|
+
def _alive(pid: int) -> bool:
|
|
159
|
+
if sys.platform == "win32": # os.kill(pid, 0) would terminate the process there; trust the lock's age instead
|
|
160
|
+
return True
|
|
161
|
+
try:
|
|
162
|
+
os.kill(pid, 0)
|
|
163
|
+
except ProcessLookupError:
|
|
164
|
+
return False
|
|
165
|
+
except PermissionError:
|
|
166
|
+
return True
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@contextlib.contextmanager
|
|
171
|
+
def _build_lock(out: Path, log: Log, *, timeout: float = 7200) -> Iterator[bool]:
|
|
172
|
+
"""Two sessions showing the same object build the same folder; the second waits for the first instead of
|
|
173
|
+
writing into it. An O_EXCL lock file holds the owner's pid; a lock whose owner is gone (or, on Windows,
|
|
174
|
+
older than the timeout) is stale. Yields whether we waited, so the caller re-checks what exists."""
|
|
175
|
+
lock = out.with_name(out.name + ".lock")
|
|
176
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
t0 = time.monotonic()
|
|
178
|
+
waited = False
|
|
179
|
+
while True:
|
|
180
|
+
try:
|
|
181
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
182
|
+
os.write(fd, str(os.getpid()).encode())
|
|
183
|
+
os.close(fd)
|
|
184
|
+
break
|
|
185
|
+
except FileExistsError:
|
|
186
|
+
try:
|
|
187
|
+
pid = int(lock.read_text().strip() or 0)
|
|
188
|
+
age = time.time() - lock.stat().st_mtime
|
|
189
|
+
except (OSError, ValueError):
|
|
190
|
+
pid, age = 0, 0.0
|
|
191
|
+
if (pid and not _alive(pid)) or age > timeout:
|
|
192
|
+
claim = lock.with_name(f"{lock.name}.stale{os.getpid()}")
|
|
193
|
+
try:
|
|
194
|
+
os.replace(lock, claim) # atomic: only one waiter wins the stale lock, the others see it gone and retry
|
|
195
|
+
claim.unlink(missing_ok=True)
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
pass
|
|
198
|
+
continue
|
|
199
|
+
if time.monotonic() - t0 > timeout:
|
|
200
|
+
raise TimeoutError(f"another process has been building {out} for over {timeout:.0f} s; remove {lock} if it is stale") from None
|
|
201
|
+
if not waited:
|
|
202
|
+
log(f"another process is building this object (pid {pid or '?'}); waiting for it")
|
|
203
|
+
waited = True
|
|
204
|
+
time.sleep(0.5)
|
|
205
|
+
try:
|
|
206
|
+
yield waited
|
|
207
|
+
finally:
|
|
208
|
+
lock.unlink(missing_ok=True)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------- the server thread
|
|
212
|
+
def _ensure_server(root: Path, host: str = "127.0.0.1", port: int | None = None) -> str:
|
|
213
|
+
from .serve import make_server
|
|
214
|
+
|
|
215
|
+
with _LOCK:
|
|
216
|
+
cur = _SERVER
|
|
217
|
+
if cur and cur["root"] == root and cur["thread"].is_alive():
|
|
218
|
+
return cur["url"]
|
|
219
|
+
httpd = make_server(root, port=port or 0, host=host, session=True)
|
|
220
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True, name="spatialscape-show")
|
|
221
|
+
thread.start()
|
|
222
|
+
url = f"http://{host}:{httpd.server_address[1]}/"
|
|
223
|
+
_SERVER.update(httpd=httpd, thread=thread, url=url, root=root)
|
|
224
|
+
return url
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def stop() -> None:
|
|
228
|
+
"""Stop this process's viewer server (bundles stay in the cache)."""
|
|
229
|
+
with _LOCK:
|
|
230
|
+
if _SERVER:
|
|
231
|
+
_SERVER["httpd"].shutdown()
|
|
232
|
+
_SERVER.clear()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------- the session
|
|
236
|
+
class Session:
|
|
237
|
+
"""A shown dataset: its URL, and the way back from a lasso to your rows."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, *, url: str, api: str, key: str, bundle: Path, adata=None, rows: dict[str, np.ndarray] | None = None, height: int = 650):
|
|
240
|
+
self.url = url
|
|
241
|
+
self.api = api
|
|
242
|
+
self.key = key
|
|
243
|
+
self.bundle = Path(bundle)
|
|
244
|
+
self.adata = adata
|
|
245
|
+
self.rows = rows
|
|
246
|
+
self.height = height
|
|
247
|
+
self._orders: dict[str, np.ndarray] = {}
|
|
248
|
+
self._ids: dict[str, np.ndarray] = {}
|
|
249
|
+
|
|
250
|
+
def __repr__(self) -> str:
|
|
251
|
+
return f"spatialscape.Session({self.url})"
|
|
252
|
+
|
|
253
|
+
def _repr_html_(self) -> str:
|
|
254
|
+
return f'<iframe src="{self.url}" style="width:100%;height:{self.height}px;border:0;border-radius:8px;background:#0e1116" allow="clipboard-write"></iframe>'
|
|
255
|
+
|
|
256
|
+
def open(self) -> None:
|
|
257
|
+
"""Open the viewer in the default browser (outside notebooks)."""
|
|
258
|
+
webbrowser.open(self.url)
|
|
259
|
+
|
|
260
|
+
def _obs(self, sid: str, name: str) -> np.ndarray:
|
|
261
|
+
import zarr
|
|
262
|
+
|
|
263
|
+
return np.asarray(zarr.open_group(str(self.bundle / "samples" / sid / "obs.zarr"), mode="r")[name][:])
|
|
264
|
+
|
|
265
|
+
def _ids_of(self, sid: str) -> np.ndarray:
|
|
266
|
+
"""Cell ids in bundle order (stored as JSON blocks, the way the viewer reads them)."""
|
|
267
|
+
d = self.bundle / "samples" / sid / "ids"
|
|
268
|
+
blocks = sorted(d.glob("*.json"), key=lambda p: int(p.stem))
|
|
269
|
+
return np.asarray([i for b in blocks for i in json.loads(b.read_text())], dtype=str)
|
|
270
|
+
|
|
271
|
+
def raw_selection(self) -> dict:
|
|
272
|
+
"""What the viewer last posted: {version, samples: {sample id: bundle-order indices}}."""
|
|
273
|
+
with urllib.request.urlopen(f"{self.api}/selection?d={self.key}", timeout=5) as r:
|
|
274
|
+
return json.loads(r.read() or b"{}")
|
|
275
|
+
|
|
276
|
+
def raw_regions(self) -> dict:
|
|
277
|
+
"""The regions drawn in the viewer: {version, groups: [{name, color}], codes: {sample id: base64 uint16}}."""
|
|
278
|
+
with urllib.request.urlopen(f"{self.api}/regions?d={self.key}", timeout=5) as r:
|
|
279
|
+
return json.loads(r.read() or b"{}")
|
|
280
|
+
|
|
281
|
+
def save_regions(self, path) -> Path:
|
|
282
|
+
"""Write the drawing (groups, colours, polygons in each section's coordinates) as JSON, the same file the
|
|
283
|
+
viewer's *save* button makes; load it again with load_regions() or `show(..., regions=path)`."""
|
|
284
|
+
doc = self.raw_regions()
|
|
285
|
+
out = Path(path).expanduser()
|
|
286
|
+
out.write_text(json.dumps({"spatialscape_regions": 1, "dataset": self.key, "groups": doc.get("groups") or []}))
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
def load_regions(self, path) -> int:
|
|
290
|
+
"""Hand a saved drawing to the session; the viewer picks it up when it (re)opens this dataset. Returns the
|
|
291
|
+
number of areas. Cell membership follows once the viewer has recomputed it, so call regions() after that."""
|
|
292
|
+
doc = json.loads(Path(path).expanduser().read_text())
|
|
293
|
+
groups = doc.get("groups") if isinstance(doc, dict) else None
|
|
294
|
+
if not isinstance(groups, list):
|
|
295
|
+
raise ValueError(f"{path}: not a regions file (expected {{groups: [...]}})")
|
|
296
|
+
body = json.dumps({"dataset": self.key, "version": 0, "groups": groups, "codes": {}}).encode()
|
|
297
|
+
req = urllib.request.Request(f"{self.api}/regions", data=body, headers={"Content-Type": "application/json"}, method="POST")
|
|
298
|
+
with urllib.request.urlopen(req, timeout=5):
|
|
299
|
+
pass
|
|
300
|
+
return sum(len(g.get("polygons") or []) for g in groups)
|
|
301
|
+
|
|
302
|
+
def regions(self) -> pd.Series | dict[str, str]:
|
|
303
|
+
"""The region each cell was drawn into (the Regions panel of the viewer), as a pandas Series indexed by obs
|
|
304
|
+
names (None outside every region) for an in-memory object, or {id: region} for data shown from a path."""
|
|
305
|
+
doc = self.raw_regions()
|
|
306
|
+
names = [g["name"] for g in doc.get("groups") or []]
|
|
307
|
+
per_sample = {sid: np.frombuffer(base64.b64decode(b64), dtype="<u2") for sid, b64 in (doc.get("codes") or {}).items()}
|
|
308
|
+
if self.adata is not None and self.rows is not None:
|
|
309
|
+
out = pd.Series([None] * self.adata.n_obs, index=self.adata.obs_names, dtype=object, name="region")
|
|
310
|
+
for sid, codes in per_sample.items():
|
|
311
|
+
if sid not in self.rows:
|
|
312
|
+
continue
|
|
313
|
+
if sid not in self._orders:
|
|
314
|
+
self._orders[sid] = self._obs(sid, "order")
|
|
315
|
+
hit = np.flatnonzero(codes < len(names))
|
|
316
|
+
rows = self.rows[sid][self._orders[sid][hit]]
|
|
317
|
+
out.iloc[rows] = [names[c] for c in codes[hit]]
|
|
318
|
+
return out
|
|
319
|
+
result: dict[str, str] = {}
|
|
320
|
+
for sid, codes in per_sample.items():
|
|
321
|
+
if sid not in self._ids:
|
|
322
|
+
self._ids[sid] = self._ids_of(sid)
|
|
323
|
+
for i in np.flatnonzero(codes < len(names)):
|
|
324
|
+
result[str(self._ids[sid][i])] = names[codes[i]]
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
def selection(self, *, as_mask: bool = False):
|
|
328
|
+
"""The cells inside the last lasso. For an in-memory object: their obs names (a pandas Index you can index
|
|
329
|
+
the object with), or with `as_mask=True` a boolean array over its rows. For data shown from a path: the ids."""
|
|
330
|
+
doc = self.raw_selection()
|
|
331
|
+
per_sample = doc.get("samples") or {}
|
|
332
|
+
if self.adata is not None and self.rows is not None:
|
|
333
|
+
parts = []
|
|
334
|
+
for sid, idx in per_sample.items():
|
|
335
|
+
if sid not in self.rows or not idx:
|
|
336
|
+
continue
|
|
337
|
+
if sid not in self._orders:
|
|
338
|
+
self._orders[sid] = self._obs(sid, "order")
|
|
339
|
+
parts.append(self.rows[sid][self._orders[sid][np.asarray(idx, dtype=np.int64)]])
|
|
340
|
+
rows = np.sort(np.concatenate(parts)) if parts else np.zeros(0, dtype=np.int64)
|
|
341
|
+
if as_mask:
|
|
342
|
+
mask = np.zeros(self.adata.n_obs, dtype=bool)
|
|
343
|
+
mask[rows] = True
|
|
344
|
+
return mask
|
|
345
|
+
names = self.adata.obs_names[rows]
|
|
346
|
+
if names.has_duplicates or (len(rows) and not self.adata.obs_names.is_unique):
|
|
347
|
+
import warnings
|
|
348
|
+
|
|
349
|
+
warnings.warn("obs names are not unique across the object (barcodes repeat between samples); index with selection(as_mask=True) instead", stacklevel=2)
|
|
350
|
+
return names
|
|
351
|
+
names: list[str] = []
|
|
352
|
+
for sid, idx in per_sample.items():
|
|
353
|
+
if not idx:
|
|
354
|
+
continue
|
|
355
|
+
if sid not in self._ids:
|
|
356
|
+
self._ids[sid] = self._ids_of(sid)
|
|
357
|
+
names.extend(self._ids[sid][np.asarray(idx, dtype=np.int64)].tolist())
|
|
358
|
+
return names
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------- show()
|
|
362
|
+
def show(
|
|
363
|
+
data,
|
|
364
|
+
*,
|
|
365
|
+
color: str | None = None,
|
|
366
|
+
coords: str | None = None,
|
|
367
|
+
sample: str | None = None,
|
|
368
|
+
platform: str | None = None,
|
|
369
|
+
fields: list[str] | dict[str, str] | str | None = None,
|
|
370
|
+
layer: str = "X",
|
|
371
|
+
normalized: str = "auto",
|
|
372
|
+
name: str | None = None,
|
|
373
|
+
height: int = 650,
|
|
374
|
+
port: int | None = None,
|
|
375
|
+
open_browser: bool = False,
|
|
376
|
+
regions=None,
|
|
377
|
+
verbose: bool = False,
|
|
378
|
+
log: Log = print,
|
|
379
|
+
) -> Session:
|
|
380
|
+
"""Show an object in the viewer and return a Session (displays inline in notebooks; `.url` elsewhere).
|
|
381
|
+
|
|
382
|
+
data: an AnnData, or a path (h5ad / zarr / SpatialData / .rds / .rda / dataset yaml / built bundle).
|
|
383
|
+
color: an obs column or a gene to color by at first. coords: obsm key (default: spatial, else the UMAP-like
|
|
384
|
+
one). sample: obs column that splits the object into one panel per value. fields: obs columns to expose
|
|
385
|
+
besides the ones picked automatically, a {field id: column} map, or "auto" for every usable column.
|
|
386
|
+
regions: a drawing saved by save_regions() or the viewer's *save* button, restored when the viewer opens.
|
|
387
|
+
"""
|
|
388
|
+
from .build import build_dataset
|
|
389
|
+
|
|
390
|
+
lines: list[str] = []
|
|
391
|
+
blog: Log = log if verbose else lines.append
|
|
392
|
+
root = cache_root()
|
|
393
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
t0 = time.perf_counter()
|
|
395
|
+
rows: dict[str, np.ndarray] | None = None
|
|
396
|
+
adata = None
|
|
397
|
+
if hasattr(data, "obs") and hasattr(data, "X"): # an AnnData
|
|
398
|
+
from . import readers
|
|
399
|
+
|
|
400
|
+
adata = data
|
|
401
|
+
coords = _coords_for(adata, coords)
|
|
402
|
+
params = {"coords": coords, "sample": sample, "platform": platform, "fields": fields, "color": color, "layer": layer, "normalized": normalized, "recipe": 2}
|
|
403
|
+
name = name or f"AnnData {adata.n_obs:,} cells x {adata.n_vars:,} features"
|
|
404
|
+
# the menu decides which obs columns are shown; they must be part of the hash, or re-clustering shows stale labels
|
|
405
|
+
cfg, rows = _config_for_adata(adata, key="pending", name=name, coords=coords, platform=platform, sample=sample, fields=fields, color=color, layer=layer, normalized=normalized)
|
|
406
|
+
used_cols = sorted({c for c in [sample, color] if c} | {c for sp in cfg.samples for c in (list(map(str, adata.obs.columns)) if sp.fields == "auto" else sp.fields.values())})
|
|
407
|
+
key = _hash_adata(adata, params, obs_columns=used_cols, obsm_key=coords[5:] if coords.startswith("obsm/") else None, layer=layer)
|
|
408
|
+
cfg.id = key
|
|
409
|
+
for sp in cfg.samples:
|
|
410
|
+
sp.path = Path(f"memory:{key}")
|
|
411
|
+
out = root / key
|
|
412
|
+
with _build_lock(out, log):
|
|
413
|
+
built = not (out / "manifest.json").exists()
|
|
414
|
+
if built:
|
|
415
|
+
readers.MEMORY[key] = adata
|
|
416
|
+
try:
|
|
417
|
+
build_dataset(cfg, out, log=blog)
|
|
418
|
+
finally:
|
|
419
|
+
readers.MEMORY.pop(key, None)
|
|
420
|
+
np.savez(out / "rows.npz", **rows)
|
|
421
|
+
else:
|
|
422
|
+
rows = dict(np.load(out / "rows.npz"))
|
|
423
|
+
else:
|
|
424
|
+
key, out, built = _show_path(Path(str(data)).expanduser(), root, color=color, coords=coords, sample=sample, platform=platform, log=blog)
|
|
425
|
+
url_root = _ensure_server(root, port=port)
|
|
426
|
+
url = f"{url_root}?d={key}"
|
|
427
|
+
if open_browser:
|
|
428
|
+
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
429
|
+
what = "built" if built else "cached"
|
|
430
|
+
log(f"{what} in {time.perf_counter() - t0:.1f} s → {url}" + ("" if built else " (same data as before: no rebuild)"))
|
|
431
|
+
session = Session(url=url, api=f"{url_root}_api", key=key, bundle=out, adata=adata, rows=rows, height=height)
|
|
432
|
+
if regions is not None:
|
|
433
|
+
n = session.load_regions(regions)
|
|
434
|
+
log(f"regions: {n} area{'s' if n != 1 else ''} from {regions}, restored when the viewer opens")
|
|
435
|
+
return session
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _show_path(path: Path, root: Path, *, color, coords, sample, platform, log: Log) -> tuple[str, Path, bool]:
|
|
439
|
+
"""A file, store, R object, dataset yaml or built bundle: `init`'s menu when needed, then an incremental build
|
|
440
|
+
keyed by the path, so a rebuilt file shows its new contents and an unchanged one is instant."""
|
|
441
|
+
from .build import build_bundle
|
|
442
|
+
from .config import load_config
|
|
443
|
+
from .init import write_menu
|
|
444
|
+
|
|
445
|
+
path = path.resolve()
|
|
446
|
+
if not path.exists():
|
|
447
|
+
raise FileNotFoundError(str(path))
|
|
448
|
+
if (path / "manifest.json").exists(): # a built bundle: link it into the served folder under its own name
|
|
449
|
+
key = _safe(path.name)
|
|
450
|
+
link = root / key
|
|
451
|
+
if link.is_symlink() or link.exists():
|
|
452
|
+
if link.resolve() != path:
|
|
453
|
+
link.unlink()
|
|
454
|
+
link.symlink_to(path, target_is_directory=True)
|
|
455
|
+
else:
|
|
456
|
+
link.symlink_to(path, target_is_directory=True)
|
|
457
|
+
return key, path, False
|
|
458
|
+
key = hashlib.sha1(str(path).encode()).hexdigest()[:12]
|
|
459
|
+
work = root / f"{key}.src"
|
|
460
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
if path.suffix in (".yaml", ".yml"):
|
|
462
|
+
cfg = load_config(path)
|
|
463
|
+
else:
|
|
464
|
+
menu = work / "dataset.yaml"
|
|
465
|
+
if not menu.exists():
|
|
466
|
+
write_menu([str(path)], out=menu, dataset_id=key, name=path.name, platform=platform, log=log)
|
|
467
|
+
cfg = load_config(menu)
|
|
468
|
+
if color or coords or sample:
|
|
469
|
+
log("note: color/coords/sample apply to in-memory objects; for files edit the menu yaml the first call wrote: " + str(menu))
|
|
470
|
+
out = root / key
|
|
471
|
+
with _build_lock(out, log):
|
|
472
|
+
res = build_bundle(cfg, out, log=log)
|
|
473
|
+
built = any(a.build for a in res.plan.actions)
|
|
474
|
+
return key, out, built
|