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.
Files changed (58) hide show
  1. spatialscape/__init__.py +13 -0
  2. spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
  3. spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
  4. spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
  5. spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
  6. spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
  7. spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
  8. spatialscape/_app/assets/expression-E359vKZW.js +368 -0
  9. spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
  10. spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
  11. spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
  12. spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
  13. spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
  14. spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
  15. spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
  16. spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
  17. spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
  18. spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
  19. spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
  20. spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
  21. spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
  22. spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
  23. spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
  24. spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
  25. spatialscape/_app/favicon.svg +161 -0
  26. spatialscape/_app/index.html +23 -0
  27. spatialscape/build.py +630 -0
  28. spatialscape/cli.py +399 -0
  29. spatialscape/config.py +570 -0
  30. spatialscape/convert.py +321 -0
  31. spatialscape/discover.py +98 -0
  32. spatialscape/expression.py +300 -0
  33. spatialscape/fingerprint.py +152 -0
  34. spatialscape/genes.py +251 -0
  35. spatialscape/geometry.py +132 -0
  36. spatialscape/images.py +240 -0
  37. spatialscape/init.py +578 -0
  38. spatialscape/manifest.py +162 -0
  39. spatialscape/obs.py +48 -0
  40. spatialscape/outlines.py +145 -0
  41. spatialscape/polygons.py +85 -0
  42. spatialscape/rds.py +609 -0
  43. spatialscape/rdsread.py +556 -0
  44. spatialscape/readers.py +195 -0
  45. spatialscape/registry.py +83 -0
  46. spatialscape/serve.py +262 -0
  47. spatialscape/session.py +474 -0
  48. spatialscape/site.py +274 -0
  49. spatialscape/synthetic.py +142 -0
  50. spatialscape/thumbnail.py +95 -0
  51. spatialscape/tracks.py +551 -0
  52. spatialscape/validate.py +193 -0
  53. spatialscape/vocab.py +188 -0
  54. spatialscape-0.3.0.dist-info/METADATA +68 -0
  55. spatialscape-0.3.0.dist-info/RECORD +58 -0
  56. spatialscape-0.3.0.dist-info/WHEEL +4 -0
  57. spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
  58. 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)