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/site.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""The gallery: `site.yaml` -> a folder you can upload (the packaged viewer, datasets.json and the bundles).
|
|
2
|
+
|
|
3
|
+
`site.yaml` is the one place that describes the gallery: its title and intro, an optional navigation bar,
|
|
4
|
+
where the bundles are hosted, and one card per dataset. A card is either a dataset yaml to build (`config:`),
|
|
5
|
+
an already built bundle (`bundle:`), or a placeholder (`id:` + `name:`). `datasets.json` is written from it
|
|
6
|
+
on every `site build` and is never edited by hand.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
19
|
+
|
|
20
|
+
from .registry import build_registry, bundle_entry, find_bundles, packaged_app_dir, placeholder_entry
|
|
21
|
+
|
|
22
|
+
Log = Callable[[str], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Model(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PaperSpec(_Model):
|
|
30
|
+
title: str
|
|
31
|
+
url: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NavLink(_Model):
|
|
35
|
+
label: str
|
|
36
|
+
url: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NavSpec(_Model):
|
|
40
|
+
"""The slim bar above the viewer and the gallery (the registry's `site` block)."""
|
|
41
|
+
|
|
42
|
+
title: str | None = None
|
|
43
|
+
logo: str | None = None
|
|
44
|
+
links: list[NavLink] = Field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SiteDataset(_Model):
|
|
48
|
+
"""One gallery card: a dataset yaml to build, an already built bundle, or a placeholder."""
|
|
49
|
+
|
|
50
|
+
config: Path | None = None # a dataset.yaml; built (incrementally) into bundles/<id> next to it
|
|
51
|
+
bundle: Path | None = None # a built bundle directory
|
|
52
|
+
id: str | None = None # a placeholder card ("coming soon"); needs `name`
|
|
53
|
+
name: str | None = None # card title; default: the dataset's name
|
|
54
|
+
description: str | None = None # card text; default: the dataset's description
|
|
55
|
+
platforms: list[str] = Field(default_factory=list) # placeholder cards only; bundles report their own
|
|
56
|
+
status: Literal["live", "local", "coming soon"] | None = None
|
|
57
|
+
paper: PaperSpec | None = None
|
|
58
|
+
tags: list[str] = Field(default_factory=list)
|
|
59
|
+
accent: str | None = None # color of the generated card art when there is no thumbnail
|
|
60
|
+
thumbnail: str | None = None # image URL; default: <bundle>/thumbnail.png
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _one_source(self):
|
|
64
|
+
n = sum(x is not None for x in (self.config, self.bundle, self.id))
|
|
65
|
+
if n != 1:
|
|
66
|
+
raise ValueError("each dataset needs exactly one of `config:` (a dataset.yaml), `bundle:` (a built bundle) or `id:` (a placeholder card)")
|
|
67
|
+
if self.id is not None and not self.name:
|
|
68
|
+
raise ValueError(f"placeholder {self.id!r}: set `name:`")
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SiteConfig(_Model):
|
|
73
|
+
title: str = "spatialscape"
|
|
74
|
+
intro: str | None = None
|
|
75
|
+
nav: NavSpec | None = None
|
|
76
|
+
data_url: str | None = None # bundles hosted elsewhere at <data_url>/<id>; nothing is copied into the site
|
|
77
|
+
datasets: list[SiteDataset]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_site_config(path: str | Path) -> SiteConfig:
|
|
81
|
+
path = Path(path).expanduser().resolve()
|
|
82
|
+
raw = yaml.safe_load(path.read_text())
|
|
83
|
+
if not isinstance(raw, dict):
|
|
84
|
+
raise ValueError(f"{path}: expected a mapping (title, datasets, ...) at the top level")
|
|
85
|
+
site = SiteConfig.model_validate(raw)
|
|
86
|
+
base = path.parent
|
|
87
|
+
for d in site.datasets:
|
|
88
|
+
for attr in ("config", "bundle"):
|
|
89
|
+
p = getattr(d, attr)
|
|
90
|
+
if p is not None:
|
|
91
|
+
p = Path(os.path.expandvars(str(p))).expanduser()
|
|
92
|
+
setattr(d, attr, p if p.is_absolute() else (base / p).resolve())
|
|
93
|
+
return site
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def copy_app(app_dir: Path, out: Path) -> None:
|
|
97
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
shutil.copy2(app_dir / "index.html", out / "index.html")
|
|
99
|
+
(out / ".nojekyll").write_text("") # GitHub Pages: serve the folder as is, no Jekyll pass over a bundle's thousands of files
|
|
100
|
+
if (app_dir / "favicon.svg").exists():
|
|
101
|
+
shutil.copy2(app_dir / "favicon.svg", out / "favicon.svg")
|
|
102
|
+
assets_out = out / "assets"
|
|
103
|
+
if assets_out.exists():
|
|
104
|
+
shutil.rmtree(assets_out)
|
|
105
|
+
shutil.copytree(app_dir / "assets", assets_out)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _viewer(app_dir: Path | None) -> Path:
|
|
109
|
+
app_dir = app_dir or packaged_app_dir()
|
|
110
|
+
if app_dir is None:
|
|
111
|
+
raise FileNotFoundError(
|
|
112
|
+
"this install has no packaged viewer. Install spatialscape from PyPI, or in a source checkout run "
|
|
113
|
+
"`npm run build` then `python python/scripts/bundle_app.py`"
|
|
114
|
+
)
|
|
115
|
+
return app_dir
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _place(bundle: Path, dest: Path, link: bool, log: Log) -> None:
|
|
119
|
+
if dest.resolve() == bundle.resolve():
|
|
120
|
+
return
|
|
121
|
+
if dest.is_symlink() or dest.is_file():
|
|
122
|
+
dest.unlink()
|
|
123
|
+
elif dest.exists():
|
|
124
|
+
shutil.rmtree(dest)
|
|
125
|
+
if link:
|
|
126
|
+
os.symlink(bundle.resolve(), dest, target_is_directory=True)
|
|
127
|
+
else:
|
|
128
|
+
# the per-sample build.json fingerprints and the config path hold paths from the building machine
|
|
129
|
+
shutil.copytree(bundle, dest, ignore=shutil.ignore_patterns("build.json"))
|
|
130
|
+
info = dest / "build-info.json"
|
|
131
|
+
if info.exists():
|
|
132
|
+
try:
|
|
133
|
+
d = json.loads(info.read_text())
|
|
134
|
+
d.pop("config", None)
|
|
135
|
+
info.write_text(json.dumps(d, indent=1))
|
|
136
|
+
except ValueError:
|
|
137
|
+
pass
|
|
138
|
+
log(f"{'linked' if link else 'copied'} {bundle} -> {dest}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _overlay(entry: dict[str, Any], d: SiteDataset) -> dict[str, Any]:
|
|
142
|
+
"""What site.yaml says about a card, on top of what its manifest says."""
|
|
143
|
+
if d.name:
|
|
144
|
+
entry["name"] = d.name
|
|
145
|
+
if d.description is not None:
|
|
146
|
+
entry["description"] = d.description
|
|
147
|
+
if d.status:
|
|
148
|
+
entry["status"] = d.status
|
|
149
|
+
if d.paper:
|
|
150
|
+
entry["paper"] = d.paper.model_dump(exclude_none=True)
|
|
151
|
+
if d.tags:
|
|
152
|
+
entry["tags"] = list(d.tags)
|
|
153
|
+
if d.accent:
|
|
154
|
+
entry["accent"] = d.accent
|
|
155
|
+
if d.thumbnail:
|
|
156
|
+
entry["thumbnail"] = d.thumbnail
|
|
157
|
+
return entry
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_site_from_config(site_yaml: Path, out: Path | None = None, *, link: bool = False, force: bool = False, app_dir: Path | None = None, log: Log = print) -> Path:
|
|
161
|
+
"""Build every `config:` dataset that changed, place the bundles, write the viewer and `datasets.json`."""
|
|
162
|
+
from .build import build_bundle
|
|
163
|
+
from .config import load_config
|
|
164
|
+
|
|
165
|
+
site_yaml = Path(site_yaml).expanduser().resolve()
|
|
166
|
+
site = load_site_config(site_yaml)
|
|
167
|
+
out = Path(out).expanduser().resolve() if out is not None else site_yaml.parent / "site"
|
|
168
|
+
app = _viewer(app_dir)
|
|
169
|
+
entries: list[dict[str, Any]] = []
|
|
170
|
+
placed = 0
|
|
171
|
+
for d in site.datasets:
|
|
172
|
+
if d.id is not None:
|
|
173
|
+
entries.append(_overlay(placeholder_entry(d.id, d.name or d.id, d.description, d.platforms), d))
|
|
174
|
+
continue
|
|
175
|
+
if d.config is not None:
|
|
176
|
+
cfg = load_config(d.config)
|
|
177
|
+
log(f"{cfg.id}: {d.config}")
|
|
178
|
+
res = build_bundle(cfg, force=force, log=log)
|
|
179
|
+
for p in res.problems:
|
|
180
|
+
log(f" ! {p}")
|
|
181
|
+
if res.problems:
|
|
182
|
+
raise ValueError(f"{cfg.id}: the bundle did not validate; fix the problems above and rerun")
|
|
183
|
+
bundle = res.out
|
|
184
|
+
else:
|
|
185
|
+
bundle = d.bundle
|
|
186
|
+
if not (bundle / "manifest.json").exists():
|
|
187
|
+
raise FileNotFoundError(f"{bundle}: no manifest.json here (not a built bundle)")
|
|
188
|
+
bid = json.loads((bundle / "manifest.json").read_text()).get("id") or bundle.name
|
|
189
|
+
if site.data_url:
|
|
190
|
+
url = f"{site.data_url.rstrip('/')}/{bid}"
|
|
191
|
+
else:
|
|
192
|
+
url = bid
|
|
193
|
+
if placed == 0:
|
|
194
|
+
copy_app(app, out)
|
|
195
|
+
log(f"viewer -> {out}")
|
|
196
|
+
_place(bundle, out / bid, link, log)
|
|
197
|
+
placed += 1
|
|
198
|
+
entries.append(_overlay(bundle_entry(bundle, url), d))
|
|
199
|
+
if placed == 0 or site.data_url:
|
|
200
|
+
copy_app(app, out)
|
|
201
|
+
log(f"viewer -> {out}")
|
|
202
|
+
doc: dict[str, Any] = {"title": site.title}
|
|
203
|
+
if site.intro:
|
|
204
|
+
doc["intro"] = site.intro
|
|
205
|
+
if site.nav:
|
|
206
|
+
doc["site"] = site.nav.model_dump(exclude_none=True)
|
|
207
|
+
doc["datasets"] = entries
|
|
208
|
+
reg = out / "datasets.json"
|
|
209
|
+
reg.write_text(json.dumps(doc, indent=2) + "\n")
|
|
210
|
+
log(f"registry -> {reg} ({len(entries)} dataset(s))")
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_site(out: Path, bundles: list[Path], *, data_url: str | None = None, link: bool = False, app_dir: Path | None = None, log: Log = print) -> Path:
|
|
215
|
+
"""A site from built bundles alone (no site.yaml): the viewer plus a registry generated from their manifests."""
|
|
216
|
+
out = Path(out).expanduser().resolve()
|
|
217
|
+
app = _viewer(app_dir)
|
|
218
|
+
expanded: list[Path] = []
|
|
219
|
+
for b in bundles:
|
|
220
|
+
found = find_bundles(Path(b))
|
|
221
|
+
if not found:
|
|
222
|
+
raise FileNotFoundError(f"{b}: no bundle here (no manifest.json in it or directly under it)")
|
|
223
|
+
expanded += found
|
|
224
|
+
copy_app(app, out)
|
|
225
|
+
log(f"viewer -> {out}")
|
|
226
|
+
if data_url is None:
|
|
227
|
+
for b in expanded:
|
|
228
|
+
bid = json.loads((b / "manifest.json").read_text()).get("id") or b.name
|
|
229
|
+
_place(b, out / bid, link, log)
|
|
230
|
+
doc = build_registry(expanded, url_for=(lambda b, bid: f"{data_url.rstrip('/')}/{bid}") if data_url else (lambda b, bid: bid))
|
|
231
|
+
reg = out / "datasets.json"
|
|
232
|
+
reg.write_text(json.dumps(doc, indent=2) + "\n")
|
|
233
|
+
log(f"registry -> {reg} ({len(doc['datasets'])} dataset(s))")
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _pad(text: str, comment: str, width: int = 44) -> str:
|
|
238
|
+
return f"{text:<{width}} # {comment}" if len(text) < width else f"{text} # {comment}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def write_site_template(items: list[Path], out: Path, *, title: str | None = None) -> Path:
|
|
242
|
+
"""A commented `site.yaml` listing the given dataset yamls and bundle folders."""
|
|
243
|
+
out = Path(out).expanduser()
|
|
244
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
base = out.parent.resolve()
|
|
246
|
+
L = [
|
|
247
|
+
"# site.yaml for spatialscape: the gallery and the viewer around it. Lines starting with # are optional.",
|
|
248
|
+
"# `spatialscape site build site.yaml` builds every `config:` dataset that changed, then writes a folder",
|
|
249
|
+
"# with the viewer, datasets.json and the bundles, ready to upload to any static host.",
|
|
250
|
+
f"title: {json.dumps(title) if title and (':' in title or '#' in title) else (title or 'My datasets')}",
|
|
251
|
+
"# intro: One or two sentences shown under the title.",
|
|
252
|
+
_pad("# nav:", "a slim bar above the viewer and the gallery"),
|
|
253
|
+
"# title: Our lab",
|
|
254
|
+
"# links: [{ label: Lab site, url: https://lab.example.org }]",
|
|
255
|
+
_pad("# data_url: https://data.example.org", "bundles hosted elsewhere at <data_url>/<id>; nothing is copied into the site"),
|
|
256
|
+
"datasets:",
|
|
257
|
+
]
|
|
258
|
+
for item in items:
|
|
259
|
+
item = Path(item).expanduser().resolve()
|
|
260
|
+
rel = os.path.relpath(item, base)
|
|
261
|
+
if item.is_dir():
|
|
262
|
+
L.append(_pad(f" - bundle: {rel}", "an already built bundle"))
|
|
263
|
+
else:
|
|
264
|
+
L.append(_pad(f" - config: {rel}", "built into bundles/<id> next to it when the data or the yaml changed"))
|
|
265
|
+
L.append(" # paper: { title: Someone et al. 2026, url: https://doi.org/... }")
|
|
266
|
+
L.append(" # tags: [cortex, visium]")
|
|
267
|
+
L += [
|
|
268
|
+
_pad(" # - id: hippocampus", 'a "coming soon" card'),
|
|
269
|
+
" # name: Human hippocampus",
|
|
270
|
+
" # description: Later this year.",
|
|
271
|
+
" # platforms: [visium, snrnaseq]",
|
|
272
|
+
]
|
|
273
|
+
out.write_text("\n".join(L) + "\n")
|
|
274
|
+
return out
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Generate a tiny synthetic dataset (AnnData + PNG image + dataset.yaml) for tests and demos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import anndata as ad
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import scipy.sparse as sp
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
CLUSTERS = ["Astro", "Excit_L2", "Excit_L5", "Inhib_PV", "Oligo", "Micro"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tissue(n: int, rng: np.random.Generator, w: float, h: float) -> np.ndarray:
|
|
17
|
+
# points inside an ellipse with a notch, in pixel units of a (h, w) image
|
|
18
|
+
pts = []
|
|
19
|
+
while len(pts) < n:
|
|
20
|
+
x, y = rng.uniform(0, w), rng.uniform(0, h)
|
|
21
|
+
nx, ny = (x - w / 2) / (w / 2), (y - h / 2) / (h / 2)
|
|
22
|
+
inside = nx * nx + ny * ny < 0.85 and not (nx > 0.3 and abs(ny) < 0.15)
|
|
23
|
+
if inside:
|
|
24
|
+
pts.append((x, y))
|
|
25
|
+
return np.array(pts)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_sample(path: Path, n: int, n_genes: int, seed: int, w: int = 512, h: int = 384, counts: bool = False):
|
|
29
|
+
rng = np.random.default_rng(seed)
|
|
30
|
+
xy = _tissue(n, rng, w, h)
|
|
31
|
+
ang = np.arctan2(xy[:, 1] - h / 2, xy[:, 0] - w / 2)
|
|
32
|
+
cl = ((ang + np.pi) / (2 * np.pi) * len(CLUSTERS)).astype(int) % len(CLUSTERS)
|
|
33
|
+
genes = [f"G{i:03d}" for i in range(n_genes)]
|
|
34
|
+
centers = rng.uniform([0, 0], [w, h], size=(n_genes, 2))
|
|
35
|
+
X = np.zeros((n, n_genes), dtype=np.float32)
|
|
36
|
+
for g in range(n_genes):
|
|
37
|
+
d2 = ((xy - centers[g]) ** 2).sum(1)
|
|
38
|
+
X[:, g] = np.exp(-d2 / (2 * (0.2 * w) ** 2)) * rng.uniform(1, 4)
|
|
39
|
+
X += (cl[:, None] == (np.arange(n_genes)[None, :] % len(CLUSTERS))) * 1.5
|
|
40
|
+
X *= rng.random(X.shape) > 0.35 # sparsity
|
|
41
|
+
if counts:
|
|
42
|
+
X = rng.poisson(X * 3).astype(np.float32)
|
|
43
|
+
obs = pd.DataFrame(
|
|
44
|
+
{
|
|
45
|
+
"cluster": pd.Categorical([CLUSTERS[i] for i in cl]),
|
|
46
|
+
"total_counts": X.sum(1) * 100 + rng.normal(0, 5, n),
|
|
47
|
+
"sample_id": path.stem,
|
|
48
|
+
},
|
|
49
|
+
index=[f"cell_{i}" for i in range(n)],
|
|
50
|
+
)
|
|
51
|
+
obs.loc[obs.index[:7], "cluster"] = np.nan # a few missing labels
|
|
52
|
+
a = ad.AnnData(X=sp.csr_matrix(X), obs=obs, var=pd.DataFrame(index=genes))
|
|
53
|
+
a.obsm["spatial"] = xy
|
|
54
|
+
a.obsm["X_umap"] = np.stack([np.cos(ang) * 3 + rng.normal(0, 0.3, n), np.sin(ang) * 3 + rng.normal(0, 0.3, n)], 1)
|
|
55
|
+
a.uns["spot_nn_spacing_level0_px"] = 10.0 # => 10 um per px
|
|
56
|
+
# fake cell polygons: wobbly hexagons around each point (pixel units, same frame as coords),
|
|
57
|
+
# stored both as obsm/segmentations (n, 6, 2) and as a long-format parquet file
|
|
58
|
+
ang_v = np.linspace(0, 2 * np.pi, 7)[:-1]
|
|
59
|
+
r = rng.uniform(2.5, 4.0, size=(n, 1)) * (1 + 0.15 * rng.standard_normal((n, 6)))
|
|
60
|
+
vx = xy[:, 0:1] + r * np.cos(ang_v)
|
|
61
|
+
vy = xy[:, 1:2] + r * np.sin(ang_v)
|
|
62
|
+
a.obsm["segmentations"] = np.stack([vx, vy], -1)
|
|
63
|
+
a.write_h5ad(path)
|
|
64
|
+
# image: gradient background + darker tissue ellipse, (h, w, 3) uint8
|
|
65
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
66
|
+
nx, ny = (xx - w / 2) / (w / 2), (yy - h / 2) / (h / 2)
|
|
67
|
+
tissue = (nx * nx + ny * ny < 0.85).astype(np.float32)
|
|
68
|
+
img = np.stack(
|
|
69
|
+
[
|
|
70
|
+
235 - 90 * tissue + 10 * nx,
|
|
71
|
+
225 - 120 * tissue,
|
|
72
|
+
230 - 80 * tissue + 10 * ny,
|
|
73
|
+
],
|
|
74
|
+
-1,
|
|
75
|
+
)
|
|
76
|
+
img = np.clip(img + rng.normal(0, 4, img.shape), 0, 255).astype(np.uint8)
|
|
77
|
+
import imageio.v3 as iio
|
|
78
|
+
|
|
79
|
+
iio.imwrite(path.with_suffix(".png"), img)
|
|
80
|
+
import pyarrow as pa
|
|
81
|
+
import pyarrow.parquet as pq
|
|
82
|
+
|
|
83
|
+
cid = np.repeat(np.asarray(obs.index), 6)
|
|
84
|
+
pq.write_table(
|
|
85
|
+
pa.table({"cell_id": cid, "vertex_x": vx.ravel(), "vertex_y": vy.ravel()}),
|
|
86
|
+
path.with_suffix(".polygons.parquet"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def make_synthetic(out: Path, n_cells: int = 2000, n_genes: int = 50) -> Path:
|
|
91
|
+
out = Path(out)
|
|
92
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
make_sample(out / "sampleA.h5ad", n_cells, n_genes, seed=1)
|
|
94
|
+
make_sample(out / "sampleB.h5ad", int(n_cells * 1.4), n_genes, seed=2, counts=True)
|
|
95
|
+
cfg = {
|
|
96
|
+
"id": "synthetic",
|
|
97
|
+
"name": "Synthetic demo",
|
|
98
|
+
"description": "Two fake tissue sections plus an embedding, for tests and the hosted demo.",
|
|
99
|
+
"default_gene": "G001",
|
|
100
|
+
"default_color": {"field": "cluster"},
|
|
101
|
+
"layout": {"mode": "grid"},
|
|
102
|
+
"fields": [
|
|
103
|
+
{"id": "cluster", "name": "Cluster", "type": "categorical", "categories": CLUSTERS},
|
|
104
|
+
{"id": "total_counts", "name": "Total counts", "type": "continuous"},
|
|
105
|
+
],
|
|
106
|
+
"samples": [
|
|
107
|
+
{
|
|
108
|
+
"id": "sampleA",
|
|
109
|
+
"name": "Sample A (lognorm)",
|
|
110
|
+
"platform": "visium",
|
|
111
|
+
"group": "donor1",
|
|
112
|
+
"path": "sampleA.h5ad",
|
|
113
|
+
"fields": {"cluster": "cluster", "total_counts": "total_counts"},
|
|
114
|
+
"images": [{"id": "he", "name": "H&E", "path": "sampleA.png"}],
|
|
115
|
+
"polygons": {"path": "sampleA.polygons.parquet"},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "sampleB",
|
|
119
|
+
"name": "Sample B (counts, flipped)",
|
|
120
|
+
"platform": "visium",
|
|
121
|
+
"group": "donor2",
|
|
122
|
+
"path": "sampleB.h5ad",
|
|
123
|
+
"transform": {"flip": "x", "rotate": 90},
|
|
124
|
+
"fields": {"cluster": "cluster", "total_counts": "total_counts"},
|
|
125
|
+
"images": [{"id": "he", "name": "H&E", "path": "sampleB.png"}],
|
|
126
|
+
"polygons": {"path": "sampleB.polygons.parquet"},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": "umapA",
|
|
130
|
+
"name": "Sample A UMAP",
|
|
131
|
+
"platform": "snrnaseq",
|
|
132
|
+
"kind": "embedding",
|
|
133
|
+
"group": "donor1",
|
|
134
|
+
"path": "sampleA.h5ad",
|
|
135
|
+
"coords": "obsm/X_umap",
|
|
136
|
+
"fields": {"cluster": "cluster", "total_counts": "total_counts"},
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
with open(out / "dataset.yaml", "w") as fh:
|
|
141
|
+
yaml.safe_dump(cfg, fh, sort_keys=False)
|
|
142
|
+
return out / "dataset.yaml"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Thumbnails: one spatial section colored by a categorical field, rendered from the bundle's own arrays."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import zarr
|
|
8
|
+
from PIL import Image, ImageColor, ImageDraw
|
|
9
|
+
|
|
10
|
+
from .manifest import Manifest
|
|
11
|
+
|
|
12
|
+
BG = (17, 21, 27)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _hex(c: str) -> tuple[int, int, int]:
|
|
16
|
+
try:
|
|
17
|
+
r, g, b = ImageColor.getrgb(c)[:3] # #rrggbb, #rgb and CSS names alike
|
|
18
|
+
return (r, g, b)
|
|
19
|
+
except ValueError:
|
|
20
|
+
return (136, 136, 136)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pick_field(m: Manifest, sample) -> str | None:
|
|
24
|
+
"""Default color field if categorical and present on the sample, else the first categorical field it carries."""
|
|
25
|
+
cats = [f.id for f in m.fields if f.type == "categorical"]
|
|
26
|
+
if m.defaultColor.kind == "field" and m.defaultColor.field in cats and m.defaultColor.field in sample.fields:
|
|
27
|
+
return m.defaultColor.field
|
|
28
|
+
for fid in cats:
|
|
29
|
+
if fid in sample.fields:
|
|
30
|
+
return fid
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_sample(root: Path, m: Manifest, sample, *, width: int = 512, height: int = 512, max_points: int = 80_000, pad: int = 16) -> Image.Image:
|
|
35
|
+
d = root / "samples" / sample.id
|
|
36
|
+
og = zarr.open_group(str(d / "obs.zarr"), mode="r")
|
|
37
|
+
xy = og["xy"][:]
|
|
38
|
+
n = len(xy)
|
|
39
|
+
step = max(1, n // max_points)
|
|
40
|
+
xy = xy[::step]
|
|
41
|
+
fid = pick_field(m, sample)
|
|
42
|
+
if fid:
|
|
43
|
+
f = m.field_by_id(fid)
|
|
44
|
+
codes = og["cat"][fid][::step]
|
|
45
|
+
colors = [_hex(c) for c in m.vocabularies[f.vocabulary].colors]
|
|
46
|
+
na = m.vocabularies[f.vocabulary].categories.index("NA") if "NA" in m.vocabularies[f.vocabulary].categories else -1
|
|
47
|
+
else:
|
|
48
|
+
codes = None
|
|
49
|
+
colors = [(200, 200, 200)]
|
|
50
|
+
na = -1
|
|
51
|
+
x0, y0 = xy.min(axis=0)
|
|
52
|
+
x1, y1 = xy.max(axis=0)
|
|
53
|
+
sw, sh = max(x1 - x0, 1e-9), max(y1 - y0, 1e-9)
|
|
54
|
+
scale = min((width - 2 * pad) / sw, (height - 2 * pad) / sh)
|
|
55
|
+
ox = (width - sw * scale) / 2
|
|
56
|
+
oy = (height - sh * scale) / 2
|
|
57
|
+
img = Image.new("RGB", (width, height), BG)
|
|
58
|
+
draw = ImageDraw.Draw(img)
|
|
59
|
+
r = max(0.6, min(3.0, 0.45 * sample.pointRadius * scale)) if sample.kind == "spatial" else 1.2
|
|
60
|
+
px = (xy[:, 0] - x0) * scale + ox
|
|
61
|
+
py = (xy[:, 1] - y0) * scale + oy
|
|
62
|
+
for i in range(len(xy)):
|
|
63
|
+
c = colors[codes[i]] if codes is not None and codes[i] < len(colors) else (120, 120, 120)
|
|
64
|
+
if codes is not None and codes[i] == na:
|
|
65
|
+
c = (70, 74, 80)
|
|
66
|
+
draw.ellipse([px[i] - r, py[i] - r, px[i] + r, py[i] + r], fill=c)
|
|
67
|
+
return img
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_thumbnails(root: Path, *, per_sample: bool = True, hero_id: str | None = None, log=lambda *_: None) -> list[Path]:
|
|
71
|
+
root = Path(root)
|
|
72
|
+
m = Manifest.model_validate_json((root / "manifest.json").read_text())
|
|
73
|
+
out: list[Path] = []
|
|
74
|
+
spatial = [s for s in m.samples if s.kind == "spatial"] or m.samples
|
|
75
|
+
if not spatial:
|
|
76
|
+
return out
|
|
77
|
+
hero_id = hero_id or m.thumbnailSample
|
|
78
|
+
hero = next((s for s in m.samples if s.id == hero_id), None) if hero_id else None
|
|
79
|
+
if hero_id and hero is None:
|
|
80
|
+
raise KeyError(f"thumbnail sample {hero_id!r} not in bundle")
|
|
81
|
+
hero = hero or next((s for s in spatial if s.id == (m.layout.order[0] if m.layout.order else None)), spatial[0])
|
|
82
|
+
img = render_sample(root, m, hero)
|
|
83
|
+
p = root / "thumbnail.png"
|
|
84
|
+
img.save(p, optimize=True)
|
|
85
|
+
out.append(p)
|
|
86
|
+
log(f"thumbnail: {hero.id} -> {p.name}")
|
|
87
|
+
if per_sample:
|
|
88
|
+
for s in m.samples:
|
|
89
|
+
img = render_sample(root, m, s, width=256, height=256, max_points=20_000, pad=8)
|
|
90
|
+
sp = root / "samples" / s.id / "thumbnail.png"
|
|
91
|
+
img.save(sp, optimize=True)
|
|
92
|
+
out.append(sp)
|
|
93
|
+
meta = {"hero": hero.id, "field": pick_field(m, hero)}
|
|
94
|
+
(root / "thumbnail.json").write_text(json.dumps(meta))
|
|
95
|
+
return out
|