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/build.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""Orchestrate: dataset.yaml -> bundle directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gc
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import scipy.sparse as sp
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .config import DatasetConfig, FieldSpec, PolygonSpec, SampleSpec
|
|
20
|
+
from .convert import SourceStatus, expand_r_objects
|
|
21
|
+
from .discover import auto_fields, infer_field_spec
|
|
22
|
+
from .expression import column_moments, detect_lognorm, get_matrix, log1p_cp10k, write_expression
|
|
23
|
+
from .fingerprint import read_sidecar, resolve_auto_images, sample_fingerprint, why_stale, write_sidecar
|
|
24
|
+
from .geometry import default_point_radius, microns_per_unit, transform_points
|
|
25
|
+
from .images import prepare_image, read_image_shape, write_ome_zarr
|
|
26
|
+
from .manifest import (
|
|
27
|
+
BUILTIN_COLORMAPS,
|
|
28
|
+
CategoricalField,
|
|
29
|
+
ColorSpec,
|
|
30
|
+
ContinuousField,
|
|
31
|
+
ExprSpec,
|
|
32
|
+
FeatureGroup,
|
|
33
|
+
ImageChannel,
|
|
34
|
+
Layout,
|
|
35
|
+
Manifest,
|
|
36
|
+
PolygonInfo,
|
|
37
|
+
Sample,
|
|
38
|
+
SampleImage,
|
|
39
|
+
)
|
|
40
|
+
from .obs import write_obs
|
|
41
|
+
from .outlines import compute_outlines, write_outlines
|
|
42
|
+
from .polygons import SCALE as POLY_SCALE
|
|
43
|
+
from .polygons import apply_affine, pack_polygons, read_polygon_obsm, read_polygon_table, write_polygons
|
|
44
|
+
from .readers import load_sample
|
|
45
|
+
from .vocab import VocabRegistry, load_palette
|
|
46
|
+
|
|
47
|
+
Log = Callable[[str], None]
|
|
48
|
+
AUTO_POLYGON_OBSM = ("segmentations", "cell_boundaries", "boundaries", "polygons")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fields_manifest(fields: list[FieldSpec], vocab: VocabRegistry):
|
|
52
|
+
out = []
|
|
53
|
+
for f in fields:
|
|
54
|
+
name = f.name or f.id
|
|
55
|
+
if f.type == "categorical":
|
|
56
|
+
out.append(CategoricalField(id=f.id, name=name, vocabulary=vocab.vocab_id(f.id), description=f.description))
|
|
57
|
+
else:
|
|
58
|
+
out.append(
|
|
59
|
+
ContinuousField(id=f.id, name=name, range=f.range, colormap=f.colormap, description=f.description)
|
|
60
|
+
)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def crop_to_points(img: np.ndarray, pixel_size: float, translate: tuple[float, float], bbox: tuple[float, float, float, float], margin: float = 0.03) -> tuple[np.ndarray, tuple[float, float]]:
|
|
65
|
+
"""Cut an image (c, y, x) placed at `translate` (um) with `pixel_size` um/px down to the points' bounding box plus
|
|
66
|
+
a margin (a fraction of its longer side), and return the image with its new translate. A slide scan reaches well
|
|
67
|
+
past the capture area; without this the H&E, not the tissue, sets the size of every grid cell."""
|
|
68
|
+
x0, y0, x1, y1 = bbox
|
|
69
|
+
pad = margin * max(x1 - x0, y1 - y0)
|
|
70
|
+
_c, h, w = img.shape
|
|
71
|
+
px0 = int(np.clip(np.floor((x0 - pad - translate[0]) / pixel_size), 0, w))
|
|
72
|
+
px1 = int(np.clip(np.ceil((x1 + pad - translate[0]) / pixel_size), 0, w))
|
|
73
|
+
py0 = int(np.clip(np.floor((y0 - pad - translate[1]) / pixel_size), 0, h))
|
|
74
|
+
py1 = int(np.clip(np.ceil((y1 + pad - translate[1]) / pixel_size), 0, h))
|
|
75
|
+
if px1 - px0 < 2 or py1 - py0 < 2: # the points lie outside the image: leave it alone
|
|
76
|
+
return img, translate
|
|
77
|
+
if (px0, py0, px1, py1) == (0, 0, w, h):
|
|
78
|
+
return img, translate
|
|
79
|
+
out = np.ascontiguousarray(img[:, py0:py1, px0:px1])
|
|
80
|
+
return out, (translate[0] + px0 * pixel_size, translate[1] + py0 * pixel_size)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mask_to_points(img: np.ndarray, pixel_size: float, translate: tuple[float, float], xy: np.ndarray, margin_um: float, *, rgb: bool) -> np.ndarray:
|
|
84
|
+
"""Blank every pixel farther than `margin_um` from any point (white for RGB, black for fluorescence), so a slide
|
|
85
|
+
image shared by several sections shows only this section's tissue."""
|
|
86
|
+
from scipy import ndimage
|
|
87
|
+
|
|
88
|
+
_c, h, w = img.shape
|
|
89
|
+
px = np.clip(((xy[:, 0] - translate[0]) / pixel_size).astype(np.int64), 0, w - 1)
|
|
90
|
+
py = np.clip(((xy[:, 1] - translate[1]) / pixel_size).astype(np.int64), 0, h - 1)
|
|
91
|
+
hit = np.zeros((h, w), dtype=bool)
|
|
92
|
+
hit[py, px] = True
|
|
93
|
+
far = ndimage.distance_transform_edt(~hit) * pixel_size > margin_um
|
|
94
|
+
out = np.array(img, copy=True)
|
|
95
|
+
out[:, far] = np.iinfo(out.dtype).max if (rgb and np.issubdtype(out.dtype, np.integer)) else (1.0 if rgb else 0)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def most_variable_gene(M, keep: np.ndarray, genes: list[str], *, seed: int = 0, max_rows: int = 4000) -> str | None:
|
|
100
|
+
"""Gene with the largest variance of normalized expression over a row subsample: a better opening gene than the
|
|
101
|
+
alphabetically first one. `M` is (n_obs, n_vars) normalized; `keep` selects the retained genes."""
|
|
102
|
+
if not genes:
|
|
103
|
+
return None
|
|
104
|
+
n = M.shape[0]
|
|
105
|
+
rows = np.sort(np.random.default_rng(seed).choice(n, size=max_rows, replace=False)) if n > max_rows else None
|
|
106
|
+
if sp.issparse(M):
|
|
107
|
+
sub = (M[rows] if rows is not None else M).tocsc()
|
|
108
|
+
_mean, var = column_moments(sub)
|
|
109
|
+
else:
|
|
110
|
+
sub = np.asarray(M, dtype=np.float64)
|
|
111
|
+
var = (sub[rows] if rows is not None else sub).var(axis=0)
|
|
112
|
+
var = var[keep]
|
|
113
|
+
if not np.isfinite(var).any():
|
|
114
|
+
return genes[0]
|
|
115
|
+
return genes[int(np.nanargmax(var))]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def suggested_default(samples: list[Sample], genes: list[str]) -> str | None:
|
|
119
|
+
"""Default gene for the dataset: the first spatial sample's most variable gene, else any sample's, else the first gene."""
|
|
120
|
+
for s in samples:
|
|
121
|
+
if s.kind == "spatial" and s.suggestedGene in genes:
|
|
122
|
+
return s.suggestedGene
|
|
123
|
+
for s in samples:
|
|
124
|
+
if s.suggestedGene in genes:
|
|
125
|
+
return s.suggestedGene
|
|
126
|
+
return genes[0] if genes else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_sample(
|
|
130
|
+
spec: SampleSpec,
|
|
131
|
+
cfg: DatasetConfig,
|
|
132
|
+
out_dir: Path,
|
|
133
|
+
vocab: VocabRegistry,
|
|
134
|
+
*,
|
|
135
|
+
sharded: bool = True,
|
|
136
|
+
outline_fields: list[str] | bool | None = None,
|
|
137
|
+
log: Log = print,
|
|
138
|
+
) -> Sample:
|
|
139
|
+
log(f"[{spec.id}] reading {spec.path}")
|
|
140
|
+
sinput = load_sample(spec)
|
|
141
|
+
adata = sinput.adata
|
|
142
|
+
n_obs = adata.n_obs
|
|
143
|
+
if sinput.name_column and not spec.gene_column:
|
|
144
|
+
log(f"[{spec.id}] gene names from var column {sinput.name_column!r} (var_names are Ensembl ids)")
|
|
145
|
+
if spec.fields == "auto":
|
|
146
|
+
spec = spec.model_copy(update={"fields": auto_fields(adata, spec)})
|
|
147
|
+
log(f"[{spec.id}] auto fields: {list(spec.fields)}")
|
|
148
|
+
if spec.images == "auto" or spec.microns == type(spec.microns)():
|
|
149
|
+
was_auto = spec.images == "auto"
|
|
150
|
+
spec = resolve_auto_images(spec)
|
|
151
|
+
if was_auto and spec.images:
|
|
152
|
+
log(f"[{spec.id}] auto image: {spec.images[0].path}")
|
|
153
|
+
for fid, col in spec.fields.items():
|
|
154
|
+
if fid not in vocab.fields:
|
|
155
|
+
if col not in adata.obs:
|
|
156
|
+
raise KeyError(f"sample {spec.id}: obs column {col!r} (field {fid}) not found")
|
|
157
|
+
vocab.declare(infer_field_spec(fid, adata, col))
|
|
158
|
+
if out_dir.exists():
|
|
159
|
+
shutil.rmtree(out_dir)
|
|
160
|
+
out_dir.mkdir(parents=True)
|
|
161
|
+
|
|
162
|
+
# --- coordinate scale first: a wrong `microns:` must not leave a half-written sample behind
|
|
163
|
+
upu = microns_per_unit(spec, sinput) # microns per source unit
|
|
164
|
+
if spec.kind == "spatial":
|
|
165
|
+
log(f"[{spec.id}] coordinates: 1 unit = {upu:.4g} um")
|
|
166
|
+
|
|
167
|
+
# --- expression -------------------------------------------------------
|
|
168
|
+
M = get_matrix(adata, spec.expression.layer)
|
|
169
|
+
mode = spec.expression.normalized
|
|
170
|
+
if mode == "auto":
|
|
171
|
+
mode = "lognorm" if detect_lognorm(M) else "counts"
|
|
172
|
+
mx = float(M.data.max()) if getattr(M, "nnz", 1) else 0.0
|
|
173
|
+
why = "non-integer values" if mode == "lognorm" else f"all-integer values, max {mx:g}"
|
|
174
|
+
log(f"[{spec.id}] expression layer {spec.expression.layer!r} looks {'log-normalized' if mode == 'lognorm' else 'like counts'} ({why})" + ("" if mode == "lognorm" else ": applying log1p(CP10k)"))
|
|
175
|
+
if mode == "counts":
|
|
176
|
+
M = log1p_cp10k(M)
|
|
177
|
+
rng = np.random.default_rng(spec.seed)
|
|
178
|
+
perm = rng.permutation(n_obs) # rows are written shuffled once, so any prefix is a uniform subsample
|
|
179
|
+
log(f"[{spec.id}] writing expression ({M.shape[1]} genes x {n_obs} obs)")
|
|
180
|
+
res = write_expression(
|
|
181
|
+
M, out_dir, shard_genes=cfg.shard_genes, sharded=sharded, keep_f16=spec.expression.keep_f16, order=perm, log=log
|
|
182
|
+
)
|
|
183
|
+
genes = [str(g) for g in sinput.gene_names[res.keep]]
|
|
184
|
+
n_dropped = int((~res.keep).sum())
|
|
185
|
+
if n_dropped:
|
|
186
|
+
log(f"[{spec.id}] {n_dropped} all-zero feature{'s' if n_dropped != 1 else ''} left out ({len(genes)} kept)")
|
|
187
|
+
with open(out_dir / "genes.json", "w") as fh:
|
|
188
|
+
json.dump(genes, fh, separators=(",", ":"))
|
|
189
|
+
suggested = most_variable_gene(M, res.keep, genes, seed=spec.seed)
|
|
190
|
+
|
|
191
|
+
# --- geometry ---------------------------------------------------------
|
|
192
|
+
coords = sinput.coords
|
|
193
|
+
auto_px_images = [im for im in spec.images if im.pixel_size == "auto"]
|
|
194
|
+
if auto_px_images:
|
|
195
|
+
_c, h, w = read_image_shape(auto_px_images[0].path, rgb=auto_px_images[0].kind == "rgb")
|
|
196
|
+
frame_w, frame_h = w / auto_px_images[0].pixels_per_unit, h / auto_px_images[0].pixels_per_unit
|
|
197
|
+
else:
|
|
198
|
+
frame_w, frame_h = float(coords[:, 0].max()), float(coords[:, 1].max())
|
|
199
|
+
xy = transform_points(coords, frame_w, frame_h, spec.transform.flip, spec.transform.rotate) * upu
|
|
200
|
+
shift = xy.min(axis=0)
|
|
201
|
+
xy = xy - shift
|
|
202
|
+
xy = xy[perm]
|
|
203
|
+
bbox = (0.0, 0.0, float(xy[:, 0].max()), float(xy[:, 1].max()))
|
|
204
|
+
radius = default_point_radius(spec, xy)
|
|
205
|
+
|
|
206
|
+
# --- obs --------------------------------------------------------------
|
|
207
|
+
cats: dict[str, np.ndarray] = {}
|
|
208
|
+
nums: dict[str, np.ndarray] = {}
|
|
209
|
+
for fid, col in spec.fields.items():
|
|
210
|
+
if col not in adata.obs:
|
|
211
|
+
raise KeyError(f"sample {spec.id}: obs column {col!r} (field {fid}) not found")
|
|
212
|
+
f = vocab.fields[fid]
|
|
213
|
+
series = adata.obs[col].iloc[perm]
|
|
214
|
+
if f.type == "categorical":
|
|
215
|
+
cats[fid] = vocab.encode(fid, series.reset_index(drop=True))
|
|
216
|
+
else:
|
|
217
|
+
arr = pd.to_numeric(series, errors="coerce").to_numpy(dtype=np.float32, na_value=np.nan)
|
|
218
|
+
scale = spec.field_scales.get(fid, 1.0)
|
|
219
|
+
nums[fid] = arr * np.float32(scale) if scale != 1.0 else arr
|
|
220
|
+
ids = [str(s) for s in adata.obs_names[perm]]
|
|
221
|
+
write_obs(out_dir, xy=xy, order=perm.astype(np.uint32), ids=ids, cats=cats, nums=nums)
|
|
222
|
+
tracks_info = None
|
|
223
|
+
if spec.tracks is not None:
|
|
224
|
+
from .tracks import write_tracks
|
|
225
|
+
|
|
226
|
+
tr = spec.tracks
|
|
227
|
+
for col, key in ((tr.barcode_column, "barcode_column"), (tr.sample_column, "sample_column")):
|
|
228
|
+
if col and col not in adata.obs:
|
|
229
|
+
raise KeyError(f"sample {spec.id}: tracks.{key} {col!r} is not an obs column")
|
|
230
|
+
barcodes = [str(b) for b in adata.obs[tr.barcode_column].iloc[perm]] if tr.barcode_column else None
|
|
231
|
+
runs = [str(r) for r in adata.obs[tr.sample_column].iloc[perm]] if tr.sample_column else None
|
|
232
|
+
tracks_info = write_tracks(
|
|
233
|
+
tr, out_dir, ids=ids, barcodes=barcodes, runs=runs, cats=cats,
|
|
234
|
+
categories={fid: list(vocab.vocabs[vocab.vocab_id(fid)]["categories"]) for fid in cats},
|
|
235
|
+
aliases={fid: dict(vocab.vocabs[vocab.vocab_id(fid)]["aliases"]) for fid in cats}, feature_names=genes, log=log,
|
|
236
|
+
)
|
|
237
|
+
outlines: list[str] = []
|
|
238
|
+
if spec.kind == "spatial" and outline_fields is not False:
|
|
239
|
+
for fid, codes in cats.items():
|
|
240
|
+
if outline_fields and fid not in outline_fields:
|
|
241
|
+
continue
|
|
242
|
+
vid = vocab.vocab_id(fid)
|
|
243
|
+
v = vocab.vocabs[vid]
|
|
244
|
+
na = {v["categories"].index("NA")} if "NA" in v["categories"] else set()
|
|
245
|
+
log(f"[{spec.id}] outlines for {fid}")
|
|
246
|
+
write_outlines(out_dir, fid, compute_outlines(xy, codes, len(v["categories"]), skip_codes=na))
|
|
247
|
+
outlines.append(fid)
|
|
248
|
+
|
|
249
|
+
# --- polygons ---------------------------------------------------------
|
|
250
|
+
poly_info = None
|
|
251
|
+
if spec.polygons is None and spec.kind == "spatial":
|
|
252
|
+
for key in AUTO_POLYGON_OBSM:
|
|
253
|
+
arr = adata.obsm.get(key, None)
|
|
254
|
+
if arr is not None and np.ndim(arr) == 3 and np.shape(arr)[2] == 2:
|
|
255
|
+
spec = spec.model_copy(update={"polygons": PolygonSpec(obsm=key)})
|
|
256
|
+
log(f"[{spec.id}] auto polygons: obsm/{key}")
|
|
257
|
+
break
|
|
258
|
+
if spec.polygons:
|
|
259
|
+
ps = spec.polygons
|
|
260
|
+
if ps.obsm:
|
|
261
|
+
log(f"[{spec.id}] polygons from obsm/{ps.obsm}")
|
|
262
|
+
pids, px_, py_ = read_polygon_obsm(adata, ps.obsm)
|
|
263
|
+
else:
|
|
264
|
+
log(f"[{spec.id}] polygons {ps.path}")
|
|
265
|
+
pids, px_, py_ = read_polygon_table(ps.path, ps.id_column, ps.x_column, ps.y_column)
|
|
266
|
+
if ps.affine:
|
|
267
|
+
px_, py_ = apply_affine(px_, py_, ps.affine)
|
|
268
|
+
pxy = (
|
|
269
|
+
transform_points(np.column_stack([px_, py_]), frame_w, frame_h, spec.transform.flip, spec.transform.rotate)
|
|
270
|
+
* upu
|
|
271
|
+
- shift
|
|
272
|
+
)
|
|
273
|
+
offsets, deltas, with_poly, dev = pack_polygons(ids, xy, pids, pxy, max_vertices=ps.max_vertices)
|
|
274
|
+
write_polygons(out_dir, offsets, deltas)
|
|
275
|
+
poly_info = PolygonInfo(cells=with_poly, vertices=int(len(deltas) // 2), scale=POLY_SCALE)
|
|
276
|
+
log(f"[{spec.id}] polygons matched {with_poly}/{n_obs} cells, {len(deltas) // 2} vertices")
|
|
277
|
+
if with_poly and dev > max(10.0, 2.0 * radius):
|
|
278
|
+
log(
|
|
279
|
+
f"[{spec.id}] warning: polygon centroids sit a median {dev:.1f} um from their cells; "
|
|
280
|
+
"the boundary file is probably in a different frame than the coordinates (see polygons.affine)"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# --- images -----------------------------------------------------------
|
|
284
|
+
images: list[SampleImage] = []
|
|
285
|
+
for im in spec.images:
|
|
286
|
+
log(f"[{spec.id}] image {im.path}")
|
|
287
|
+
rgb = im.kind == "rgb"
|
|
288
|
+
img = prepare_image(im.path, spec.transform.flip, spec.transform.rotate, im.max_size, rgb=rgb)
|
|
289
|
+
if im.pixel_size == "auto":
|
|
290
|
+
# pixels_per_unit image px per coord unit; after max_size downsampling the ratio changes
|
|
291
|
+
_c, h0, w0 = read_image_shape(im.path, rgb=rgb)
|
|
292
|
+
ds = max(h0, w0) / max(img.shape[1], img.shape[2]) if im.max_size else 1.0
|
|
293
|
+
pixel_size = upu / im.pixels_per_unit * ds
|
|
294
|
+
else:
|
|
295
|
+
pixel_size = float(im.pixel_size)
|
|
296
|
+
translate = (float(-shift[0] + im.translate[0]), float(-shift[1] + im.translate[1]))
|
|
297
|
+
if im.crop:
|
|
298
|
+
img, translate = crop_to_points(img, pixel_size, translate, bbox)
|
|
299
|
+
log(f"[{spec.id}] image cropped to the points: {img.shape[2]} x {img.shape[1]} px")
|
|
300
|
+
if im.mask:
|
|
301
|
+
img = mask_to_points(img, pixel_size, translate, xy, im.mask_margin, rgb=rgb)
|
|
302
|
+
log(f"[{spec.id}] image blanked beyond {im.mask_margin:g} um of the sample's own points (a slide image shared with another section)")
|
|
303
|
+
names = [ch.name for ch in im.channels] if im.channels else None
|
|
304
|
+
colors = [ch.color for ch in im.channels] if im.channels else None
|
|
305
|
+
wins = [ch.window for ch in im.channels] if im.channels and all(ch.window for ch in im.channels) else None
|
|
306
|
+
out_path = out_dir / f"{im.id}.ome.zarr"
|
|
307
|
+
write_ome_zarr(img, out_path, rgb=rgb, channel_names=names, channel_colors=colors, windows=wins)
|
|
308
|
+
chans = None
|
|
309
|
+
if im.channels:
|
|
310
|
+
maxv = 255 if img.dtype == np.uint8 else int(np.iinfo(img.dtype).max)
|
|
311
|
+
chans = [ImageChannel(name=ch.name, color=ch.color, window=ch.window or (0, maxv)) for ch in im.channels]
|
|
312
|
+
images.append(
|
|
313
|
+
SampleImage(
|
|
314
|
+
id=im.id,
|
|
315
|
+
name=im.name,
|
|
316
|
+
path=out_path.name,
|
|
317
|
+
kind=im.kind,
|
|
318
|
+
pixelSize=pixel_size,
|
|
319
|
+
translate=translate,
|
|
320
|
+
size=(int(img.shape[1]), int(img.shape[2])),
|
|
321
|
+
channels=chans,
|
|
322
|
+
defaultOpacity=im.default_opacity,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return Sample(
|
|
327
|
+
id=spec.id,
|
|
328
|
+
name=spec.name or spec.id,
|
|
329
|
+
platform=spec.platform,
|
|
330
|
+
kind=spec.kind,
|
|
331
|
+
group=spec.group,
|
|
332
|
+
nObs=n_obs,
|
|
333
|
+
nGenes=len(genes),
|
|
334
|
+
bbox=bbox,
|
|
335
|
+
pointRadius=radius,
|
|
336
|
+
expr=ExprSpec(kind="u8", sharded=res.sharded, shardGenes=res.shard_genes, normalized="log1p_cp10k" if mode == "counts" else "lognorm"),
|
|
337
|
+
hasF16=spec.expression.keep_f16,
|
|
338
|
+
fields=list(spec.fields.keys()),
|
|
339
|
+
images=images,
|
|
340
|
+
outlines=outlines,
|
|
341
|
+
polygons=poly_info,
|
|
342
|
+
suggestedGene=suggested,
|
|
343
|
+
tracks=tracks_info,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _any_feature(genes: list[str], groups: list[dict]) -> str:
|
|
348
|
+
"""Something to color by when the dataset has no categorical field and no default: the first gene, else the
|
|
349
|
+
first grouped feature (a peaks-only dataset), else a clear error instead of an IndexError after a long build."""
|
|
350
|
+
if genes:
|
|
351
|
+
return genes[0]
|
|
352
|
+
for g in groups:
|
|
353
|
+
if g.get("features"):
|
|
354
|
+
return g["features"][0]["id"]
|
|
355
|
+
raise ValueError("the dataset has no features to color by; check `expression:` and the inputs")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _split_features(cfg: DatasetConfig, names: list[str]) -> tuple[list[str], list[dict]]:
|
|
359
|
+
"""Split the feature union into plain genes and configured groups (first matching pattern wins)."""
|
|
360
|
+
groups = [{"id": g.id, "name": g.name, "units": g.units, "features": []} for g in cfg.feature_groups]
|
|
361
|
+
pats = [(re.compile(g.pattern), re.compile(g.strip or g.pattern)) for g in cfg.feature_groups]
|
|
362
|
+
genes: list[str] = []
|
|
363
|
+
for n in names:
|
|
364
|
+
for gi, (pat, strip) in enumerate(pats):
|
|
365
|
+
if pat.search(n):
|
|
366
|
+
groups[gi]["features"].append({"id": n, "label": strip.sub("", n).strip() or n})
|
|
367
|
+
break
|
|
368
|
+
else:
|
|
369
|
+
genes.append(n)
|
|
370
|
+
for g in groups:
|
|
371
|
+
g["features"].sort(key=lambda f: f["label"].lower())
|
|
372
|
+
return genes, groups
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _all_fields(cfg: DatasetConfig, vocab: VocabRegistry) -> list[FieldSpec]:
|
|
376
|
+
declared = {f.id: f for f in cfg.fields}
|
|
377
|
+
out = list(cfg.fields)
|
|
378
|
+
out += [f for fid, f in vocab.fields.items() if fid not in declared]
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _write_dataset_files(out: Path, cfg: DatasetConfig, samples: list[Sample], vocab: VocabRegistry) -> Manifest:
|
|
383
|
+
union: set[str] = set()
|
|
384
|
+
for s in samples:
|
|
385
|
+
with open(out / "samples" / s.id / "genes.json") as fh:
|
|
386
|
+
union.update(json.load(fh))
|
|
387
|
+
genes, groups = _split_features(cfg, sorted(union))
|
|
388
|
+
with open(out / "genes.json", "w") as fh:
|
|
389
|
+
json.dump(genes, fh, separators=(",", ":"))
|
|
390
|
+
with open(out / "features.json", "w") as fh:
|
|
391
|
+
json.dump({"genes": genes, "groups": groups}, fh, separators=(",", ":"))
|
|
392
|
+
|
|
393
|
+
if cfg.default_color is not None:
|
|
394
|
+
dc = (
|
|
395
|
+
ColorSpec(kind="gene", gene=cfg.default_color.gene)
|
|
396
|
+
if cfg.default_color.gene
|
|
397
|
+
else ColorSpec(kind="field", field=cfg.default_color.field)
|
|
398
|
+
)
|
|
399
|
+
elif cfg.default_gene:
|
|
400
|
+
dc = ColorSpec(kind="gene", gene=cfg.default_gene)
|
|
401
|
+
else:
|
|
402
|
+
cat_fields = [f for f in cfg.fields if f.type == "categorical"]
|
|
403
|
+
dc = ColorSpec(kind="field", field=cat_fields[0].id) if cat_fields else ColorSpec(kind="gene", gene=suggested_default(samples, genes) or _any_feature(genes, groups))
|
|
404
|
+
default_gene = cfg.default_gene or (dc.gene if dc.kind == "gene" else suggested_default(samples, genes))
|
|
405
|
+
order = cfg.layout.order or [s.id for s in samples]
|
|
406
|
+
manifest = Manifest(
|
|
407
|
+
id=cfg.id,
|
|
408
|
+
name=cfg.name,
|
|
409
|
+
description=cfg.description,
|
|
410
|
+
defaultGene=default_gene,
|
|
411
|
+
defaultColor=dc,
|
|
412
|
+
layout=Layout(mode=cfg.layout.mode, gutterFraction=cfg.layout.gutter_fraction, order=order, columns=cfg.layout.columns),
|
|
413
|
+
colormaps=cfg.colormaps or list(BUILTIN_COLORMAPS),
|
|
414
|
+
vocabularies=vocab.to_manifest(),
|
|
415
|
+
fields=_fields_manifest(_all_fields(cfg, vocab), vocab),
|
|
416
|
+
featureGroups=[
|
|
417
|
+
FeatureGroup(id=g["id"], name=g["name"], units=g["units"], count=len(g["features"])) for g in groups
|
|
418
|
+
],
|
|
419
|
+
samples=samples,
|
|
420
|
+
thumbnailSample=cfg.thumbnail_sample,
|
|
421
|
+
)
|
|
422
|
+
with open(out / "manifest.json", "w") as fh:
|
|
423
|
+
fh.write(manifest.model_dump_json(indent=1, exclude_none=True))
|
|
424
|
+
with open(out / "build-info.json", "w") as fh:
|
|
425
|
+
json.dump({"spatialscape": __version__, "config": str(cfg.path) if cfg.path else None, "built": datetime.now(UTC).isoformat(timespec="seconds")}, fh, indent=1)
|
|
426
|
+
return manifest
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def read_manifest(out: Path) -> Manifest | None:
|
|
430
|
+
p = Path(out) / "manifest.json"
|
|
431
|
+
if not p.exists():
|
|
432
|
+
return None
|
|
433
|
+
try:
|
|
434
|
+
return Manifest.model_validate_json(p.read_text())
|
|
435
|
+
except Exception:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass
|
|
440
|
+
class SampleAction:
|
|
441
|
+
spec: SampleSpec
|
|
442
|
+
build: bool
|
|
443
|
+
reason: str # new | source changed | settings changed | field declarations changed | format changed | forced | unchanged
|
|
444
|
+
fingerprint: dict
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@dataclass
|
|
448
|
+
class BuildPlan:
|
|
449
|
+
"""What a `build` would do to a bundle directory. Pure: computed from the yaml, the input files and the sidecars."""
|
|
450
|
+
|
|
451
|
+
out: Path
|
|
452
|
+
actions: list[SampleAction]
|
|
453
|
+
removed: list[str] # sample folders in the bundle that the yaml no longer lists
|
|
454
|
+
existing: Manifest | None
|
|
455
|
+
sources: list[SourceStatus] = field(default_factory=list)
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def to_build(self) -> list[SampleAction]:
|
|
459
|
+
return [a for a in self.actions if a.build]
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def reused(self) -> list[SampleAction]:
|
|
463
|
+
return [a for a in self.actions if not a.build]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def plan_build(cfg: DatasetConfig, out: Path, *, sharded: bool = True, force: bool = False) -> BuildPlan:
|
|
467
|
+
out = Path(out)
|
|
468
|
+
existing = read_manifest(out)
|
|
469
|
+
present = {s.id for s in existing.samples} if existing else set()
|
|
470
|
+
actions: list[SampleAction] = []
|
|
471
|
+
for spec in cfg.samples:
|
|
472
|
+
fp = sample_fingerprint(spec, cfg, sharded=sharded)
|
|
473
|
+
d = out / "samples" / spec.id
|
|
474
|
+
if force:
|
|
475
|
+
reason: str | None = "forced"
|
|
476
|
+
elif spec.id not in present or not (d / "expr.zarr").exists():
|
|
477
|
+
reason = "new"
|
|
478
|
+
else:
|
|
479
|
+
reason = why_stale(read_sidecar(d), fp)
|
|
480
|
+
actions.append(SampleAction(spec, reason is not None, reason or "unchanged", fp))
|
|
481
|
+
wanted = {s.id for s in cfg.samples}
|
|
482
|
+
folders = sorted(p.name for p in (out / "samples").iterdir() if p.is_dir()) if (out / "samples").is_dir() else []
|
|
483
|
+
return BuildPlan(out, actions, [f for f in folders if f not in wanted], existing)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def describe_plan(plan: BuildPlan) -> list[str]:
|
|
487
|
+
lines: list[str] = []
|
|
488
|
+
if plan.sources:
|
|
489
|
+
lines.append("sources")
|
|
490
|
+
for st in plan.sources:
|
|
491
|
+
lines.append(f" {st.entry.path}: {st.label}")
|
|
492
|
+
if plan.actions:
|
|
493
|
+
w = max(len(a.spec.id) for a in plan.actions)
|
|
494
|
+
for a in plan.actions:
|
|
495
|
+
lines.append(f" {a.spec.id:<{w}} {'build' if a.build else 'skip '} {a.reason}")
|
|
496
|
+
for sid in plan.removed:
|
|
497
|
+
lines.append(f" {sid}: removed (no longer in the yaml)")
|
|
498
|
+
n_build, n_skip = len(plan.to_build), len(plan.reused)
|
|
499
|
+
pending = sum(st.state == "pending" for st in plan.sources)
|
|
500
|
+
if pending:
|
|
501
|
+
lines.append(f"{pending} R object{'s' if pending != 1 else ''} to convert first (their samples are listed once converted)")
|
|
502
|
+
lines.append(f"{n_build} sample{'s' if n_build != 1 else ''} to build, {n_skip} unchanged; manifest.json, genes.json, features.json and thumbnails are rewritten every time")
|
|
503
|
+
return lines
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _registry_for(cfg: DatasetConfig, existing: Manifest | None, reused: list[SampleAction]) -> VocabRegistry:
|
|
507
|
+
"""Fresh vocabularies when every sample is rebuilt; otherwise the vocabularies that reused samples depend on are
|
|
508
|
+
kept from the existing manifest so their codes stay valid, and new labels append. Palette colors always apply."""
|
|
509
|
+
palette = load_palette(cfg.palette)
|
|
510
|
+
if existing is None or not reused:
|
|
511
|
+
return VocabRegistry(cfg.fields, palette)
|
|
512
|
+
by_id = {s.id: s for s in existing.samples}
|
|
513
|
+
reused_fields = {fid for a in reused for fid in by_id[a.spec.id].fields}
|
|
514
|
+
keep_vocabs = {f.vocabulary for fid in reused_fields if (f := existing.field_by_id(fid)) is not None and f.type == "categorical"}
|
|
515
|
+
vocab = VocabRegistry(cfg.fields, palette, existing={vid: v for vid, v in existing.vocabularies.items() if vid in keep_vocabs})
|
|
516
|
+
vocab.adopt_manifest_fields(existing, only=reused_fields)
|
|
517
|
+
vocab.recolor_from_palette()
|
|
518
|
+
return vocab
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def build_dataset(cfg: DatasetConfig, out: Path, *, sharded: bool = True, force: bool = False, plan: BuildPlan | None = None, log: Log = print) -> Manifest:
|
|
522
|
+
"""Bring the bundle directory in line with the yaml: build new and changed samples, keep the rest, drop
|
|
523
|
+
samples the yaml no longer lists, and rewrite the dataset-level files."""
|
|
524
|
+
out = Path(out)
|
|
525
|
+
(out / "samples").mkdir(parents=True, exist_ok=True)
|
|
526
|
+
plan = plan or plan_build(cfg, out, sharded=sharded, force=force)
|
|
527
|
+
vocab = _registry_for(cfg, plan.existing, plan.reused)
|
|
528
|
+
by_id = {s.id: s for s in plan.existing.samples} if plan.existing else {}
|
|
529
|
+
samples: list[Sample] = []
|
|
530
|
+
built: list[tuple[Path, dict]] = []
|
|
531
|
+
for a in plan.actions:
|
|
532
|
+
if a.build:
|
|
533
|
+
s = build_sample(a.spec, cfg, out / "samples" / a.spec.id, vocab, sharded=sharded, log=log)
|
|
534
|
+
gc.collect() # an AnnData is a reference cycle: free this sample's object before the next one is read
|
|
535
|
+
built.append((out / "samples" / a.spec.id, a.fingerprint))
|
|
536
|
+
else:
|
|
537
|
+
s = by_id[a.spec.id].model_copy(update={"name": a.spec.name or a.spec.id, "group": a.spec.group})
|
|
538
|
+
samples.append(s)
|
|
539
|
+
for sid in plan.removed:
|
|
540
|
+
shutil.rmtree(out / "samples" / sid, ignore_errors=True)
|
|
541
|
+
log(f"removed sample {sid}")
|
|
542
|
+
manifest = _write_dataset_files(out, cfg, samples, vocab)
|
|
543
|
+
# Sidecars last: a sample is "current" only together with the manifest whose vocabularies its codes were
|
|
544
|
+
# assigned against. A run that dies before this point leaves the new folders without sidecars (rebuilt next
|
|
545
|
+
# time) and the old manifest intact, instead of a sample that decodes against the wrong categories.
|
|
546
|
+
for d, fp in built:
|
|
547
|
+
write_sidecar(d, fp)
|
|
548
|
+
return manifest
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass
|
|
552
|
+
class BuildResult:
|
|
553
|
+
out: Path
|
|
554
|
+
plan: BuildPlan
|
|
555
|
+
manifest: Manifest | None # None for a dry run
|
|
556
|
+
problems: list[str] = field(default_factory=list)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def build_bundle(cfg: DatasetConfig, out: Path | None = None, *, sharded: bool | None = None, force: bool = False, dry_run: bool = False, log: Log = print) -> BuildResult:
|
|
560
|
+
"""Everything `spatialscape build` does: convert R objects into the cache, plan, build, render thumbnails,
|
|
561
|
+
validate. `site build` runs the same for every dataset yaml it lists. `sharded` defaults to the yaml's
|
|
562
|
+
`sharded:` so a bundle built for a host without Range support stays that way in every later build."""
|
|
563
|
+
if sharded is None:
|
|
564
|
+
sharded = cfg.sharded
|
|
565
|
+
from .thumbnail import write_thumbnails
|
|
566
|
+
from .validate import validate_local
|
|
567
|
+
|
|
568
|
+
out = Path(out) if out is not None else cfg.default_bundle_dir
|
|
569
|
+
cache_root = out.parent / ".cache" / out.name
|
|
570
|
+
cfg, sources = expand_r_objects(cfg, cache_root, convert=not dry_run, log=log)
|
|
571
|
+
plan = plan_build(cfg, out, sharded=sharded, force=force)
|
|
572
|
+
plan.sources = sources
|
|
573
|
+
for line in describe_plan(plan):
|
|
574
|
+
log(line)
|
|
575
|
+
if dry_run:
|
|
576
|
+
return BuildResult(out, plan, None)
|
|
577
|
+
m = build_dataset(cfg, out, sharded=sharded, force=force, plan=plan, log=log)
|
|
578
|
+
try:
|
|
579
|
+
write_thumbnails(out, hero_id=cfg.thumbnail_sample, log=log)
|
|
580
|
+
except Exception as e: # thumbnails are a nicety; never fail a build over them
|
|
581
|
+
log(f"thumbnails skipped: {e}")
|
|
582
|
+
return BuildResult(out, plan, m, validate_local(out))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def add_outlines(
|
|
586
|
+
cfg: DatasetConfig,
|
|
587
|
+
out: Path,
|
|
588
|
+
*,
|
|
589
|
+
sample_ids: list[str] | None = None,
|
|
590
|
+
fields: list[str] | None = None,
|
|
591
|
+
smooth_um: float = 150.0,
|
|
592
|
+
min_feature_um: float = 300.0,
|
|
593
|
+
log: Log = print,
|
|
594
|
+
) -> Manifest:
|
|
595
|
+
"""Compute outlines for categorical fields from the arrays already in the bundle (no source data needed)."""
|
|
596
|
+
import zarr
|
|
597
|
+
|
|
598
|
+
out = Path(out)
|
|
599
|
+
with open(out / "manifest.json") as fh:
|
|
600
|
+
existing = Manifest.model_validate_json(fh.read())
|
|
601
|
+
vocab = VocabRegistry(cfg.fields, load_palette(cfg.palette), existing=existing.vocabularies)
|
|
602
|
+
vocab.adopt_manifest_fields(existing)
|
|
603
|
+
vocab.recolor_from_palette() # palette edits apply on refresh; codes stay put
|
|
604
|
+
for s in existing.samples:
|
|
605
|
+
if sample_ids and s.id not in sample_ids:
|
|
606
|
+
continue
|
|
607
|
+
if s.kind != "spatial":
|
|
608
|
+
continue
|
|
609
|
+
d = out / "samples" / s.id
|
|
610
|
+
og = zarr.open_group(str(d / "obs.zarr"), mode="r")
|
|
611
|
+
xy = og["xy"][:]
|
|
612
|
+
done: list[str] = []
|
|
613
|
+
for fid in s.fields:
|
|
614
|
+
f = existing.field_by_id(fid)
|
|
615
|
+
if not f or f.type != "categorical" or (fields and fid not in fields):
|
|
616
|
+
continue
|
|
617
|
+
codes = og["cat"][fid][:]
|
|
618
|
+
v = existing.vocabularies[f.vocabulary]
|
|
619
|
+
na = {v.categories.index("NA")} if "NA" in v.categories else set()
|
|
620
|
+
log(f"[{s.id}] outlines for {fid}")
|
|
621
|
+
write_outlines(
|
|
622
|
+
d,
|
|
623
|
+
fid,
|
|
624
|
+
compute_outlines(
|
|
625
|
+
xy, codes, len(v.categories), skip_codes=na, smooth_um=smooth_um, min_feature_um=min_feature_um
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
done.append(fid)
|
|
629
|
+
s.outlines = sorted(set(s.outlines) | set(done))
|
|
630
|
+
return _write_dataset_files(out, cfg, existing.samples, vocab)
|