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
@@ -0,0 +1,321 @@
1
+ """R objects (.rds / .rda) as build inputs: converted once into a cache next to the bundle, then expanded into samples.
2
+
3
+ A `samples:` entry whose `path` is an R object is converted on the first `build` into
4
+ `<bundles>/.cache/<dataset id>/<key>/`, where `<key>` fingerprints the file (path, size, mtime) and the
5
+ conversion settings (sample_col, assay, embedding, object). The cache holds every colData column, so editing
6
+ `fields:` in the yaml never triggers a reconversion; a re-saved object or a changed `assay:` makes a new key.
7
+
8
+ Spatial objects (anything with spatialCoords) become one folder per sample in the SpaceRanger-like layout
9
+ that `images: auto` and the micron inference understand:
10
+
11
+ <key>/<sample>/adata.h5ad X = assay, obs = all colData, obsm/spatial = spatialCoords
12
+ <key>/<sample>/spatial/tissue_<id>_image.png from imgData (loaded rasters, or stored image files)
13
+ <key>/<sample>/spatial/scalefactors_json.json tissue_<id>_scalef + a spot diameter measured from the spacing
14
+
15
+ Objects without spatialCoords (snRNA-seq references) become <key>/<stem>/adata.h5ad with obsm/X_umap.
16
+ Either way the entry is rewritten into `glob: <key>/*/adata.h5ad` and expanded like any other glob entry.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import re
23
+ import shutil
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+
29
+ import anndata as ad
30
+ import numpy as np
31
+ import pandas as pd
32
+ import scipy.sparse as sp
33
+
34
+ from .config import DatasetConfig, RObjectEntry, _merged_entry, config_from_raw
35
+ from .rds import RExperiment, RImage, read_experiment
36
+
37
+ Log = Callable[[str], None]
38
+ CONVERT_VERSION = 3 # bump when the cache layout or its contents change for the same object (3: feature names from the assay dimnames)
39
+ VISIUM_SPOT_UM = 55.0
40
+ VISIUM_SPACING_UM = 100.0
41
+ EMBEDDING_PREFERENCE = ("UMAP", "umap", "X_umap", "UMAP-HARMONY", "UMAP_HARMONY", "UMAP.HARMONY")
42
+ LAYER_ALIASES = {"logcounts": "data", "normcounts": "data"} # Bioconductor assay names -> Seurat layer names
43
+
44
+
45
+ def pick_embedding(keys) -> str | None:
46
+ """The reducedDim / reduction to draw when none is named: the usual UMAP names, else anything containing
47
+ "umap" (a joint `wnn.umap` before a per-modality one), else t-SNE, else the first."""
48
+ keys = list(keys)
49
+ for k in EMBEDDING_PREFERENCE:
50
+ if k in keys:
51
+ return k
52
+ umaps = [k for k in keys if "umap" in k.lower()]
53
+ if umaps:
54
+ return next((k for k in umaps if "wnn" in k.lower()), umaps[0])
55
+ return next((k for k in keys if "tsne" in k.lower()), keys[0] if keys else None)
56
+ INDEX = "index.json"
57
+
58
+
59
+ @dataclass
60
+ class SourceStatus:
61
+ """What `build` found for one R object entry."""
62
+
63
+ entry: RObjectEntry
64
+ key: str | None
65
+ cache_dir: Path | None
66
+ state: str # cached | converted | pending | missing
67
+ samples: list[str] = field(default_factory=list)
68
+
69
+ @property
70
+ def label(self) -> str:
71
+ return {
72
+ "cached": f"cached ({len(self.samples)} sample{'s' if len(self.samples) != 1 else ''})",
73
+ "converted": f"converted ({len(self.samples)} sample{'s' if len(self.samples) != 1 else ''})",
74
+ "pending": "not converted yet; `build` converts it, then lists its samples",
75
+ "missing": "file not found",
76
+ }[self.state]
77
+
78
+
79
+ def _safe(name: str) -> str:
80
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name))
81
+
82
+
83
+ def cache_key(entry: RObjectEntry) -> str:
84
+ """Identity of a conversion: the file (path, size, mtime) and the settings that change its output."""
85
+ st = entry.path.stat()
86
+ parts = [str(entry.path.resolve()), st.st_size, st.st_mtime_ns, entry.sample_col, entry.assay, entry.embedding, entry.object, CONVERT_VERSION]
87
+ return hashlib.sha1(json.dumps(parts).encode()).hexdigest()[:16]
88
+
89
+
90
+ def read_index(cache_dir: Path) -> dict | None:
91
+ p = Path(cache_dir) / INDEX
92
+ if not p.exists():
93
+ return None
94
+ try:
95
+ return json.loads(p.read_text())
96
+ except (OSError, ValueError):
97
+ return None
98
+
99
+
100
+ def _anndata(x: RExperiment, assay: str, rows: np.ndarray | None) -> ad.AnnData:
101
+ layer = assay
102
+ if layer not in x.assays:
103
+ alias = LAYER_ALIASES.get(assay)
104
+ if x.class_name == "Seurat" and alias in x.assays:
105
+ layer = alias
106
+ else:
107
+ raise KeyError(f"assay {assay!r} not found; available: {', '.join(x.assays)}")
108
+ mats = [x.assays[layer]]
109
+ var = x.var.copy()
110
+ for fs in x.feature_sets: # a Seurat object's other assays, as prefixed features under the genes
111
+ fl = layer if layer in fs.assays else "data" if "data" in fs.assays else next(iter(fs.assays))
112
+ mats.append(fs.assays[fl])
113
+ v = fs.var.copy()
114
+ v.index = pd.Index([f"{fs.prefix}{i}" for i in v.index.astype(str)])
115
+ var = pd.concat([var, v])
116
+ if len(mats) > 1:
117
+ m = sp.vstack([a if sp.issparse(a) else sp.csc_matrix(np.asarray(a)) for a in mats]).tocsc()
118
+ else:
119
+ m = mats[0]
120
+ if rows is not None:
121
+ m = m[:, rows]
122
+ X = m.T.tocsr() if sp.issparse(m) else np.ascontiguousarray(np.asarray(m).T)
123
+ obs = x.obs if rows is None else x.obs.iloc[rows]
124
+ for c in var.columns:
125
+ if var[c].dtype == object:
126
+ var[c] = var[c].astype("category")
127
+ if var.index.has_duplicates: # duplicate symbols get -1, -2 suffixes (what var_names_make_unique would do)
128
+ var.index = ad.utils.make_index_unique(pd.Index(var.index.astype(str)), join="-")
129
+ return ad.AnnData(X=X, obs=obs.copy(), var=var)
130
+
131
+
132
+ def _write_image(img: RImage, dest: Path) -> bool:
133
+ import imageio.v3 as iio
134
+
135
+ if img.rgb is not None:
136
+ iio.imwrite(dest, img.rgb)
137
+ return True
138
+ if img.path and Path(img.path).exists():
139
+ arr = np.asarray(iio.imread(img.path))
140
+ if arr.ndim == 2: # grayscale: three equal planes, never a 3-pixel-wide slice
141
+ arr = np.stack([arr, arr, arr], axis=-1)
142
+ iio.imwrite(dest, arr[..., :3])
143
+ return True
144
+ return False
145
+
146
+
147
+ def _write_spatial(x: RExperiment, out: Path, *, assay: str, sample_col: str, log: Log) -> list[str]:
148
+ if sample_col not in x.obs.columns:
149
+ raise KeyError(f"sample column {sample_col!r} not in colData; available: {', '.join(x.obs.columns)}")
150
+ sample_values = x.obs[sample_col].astype(str).to_numpy()
151
+ names: list[str] = []
152
+ for s in pd.unique(sample_values):
153
+ rows = np.flatnonzero(sample_values == s)
154
+ d = out / _safe(s)
155
+ (d / "spatial").mkdir(parents=True, exist_ok=True)
156
+ a = _anndata(x, assay, rows)
157
+ coords = x.spatial[rows]
158
+ a.obsm["spatial"] = np.asarray(coords, dtype=np.float64)
159
+ sf: dict[str, float] = {}
160
+ for img in [i for i in x.images if i.sample_id == s]:
161
+ if _write_image(img, d / "spatial" / f"tissue_{_safe(img.image_id)}_image.png"):
162
+ sf[f"tissue_{_safe(img.image_id)}_scalef"] = img.scale_factor
163
+ else:
164
+ log(f" warning: image {img.image_id!r} of sample {s} could not be written ({img.path or 'no pixels in the object'})")
165
+ finite = coords[np.isfinite(coords).all(axis=1)]
166
+ if len(finite) > 1: # Visium rule: neighbouring spots are 100 um apart, a spot is 55 um wide
167
+ from scipy.spatial import cKDTree
168
+
169
+ nn = cKDTree(finite).query(finite, k=2)[0][:, 1]
170
+ spacing = float(np.median(nn))
171
+ if spacing > 0:
172
+ sf["spot_diameter_fullres"] = spacing * VISIUM_SPOT_UM / VISIUM_SPACING_UM
173
+ else:
174
+ log(f" warning: {d.name}: coordinates repeat (median spot spacing 0); set `microns:` for this sample")
175
+ (d / "spatial" / "scalefactors_json.json").write_text(json.dumps(sf, indent=1))
176
+ a.write_h5ad(d / "adata.h5ad")
177
+ names.append(d.name)
178
+ log(f" {d.name}: {a.n_obs} cells, {len([k for k in sf if k.startswith('tissue_')])} image(s)")
179
+ ids = {i.image_id for i in x.images}
180
+ if ids and ids <= {"lowres"}:
181
+ log(" note: the object holds only the low-resolution H&E (SpaceRanger's tissue_lowres_image, about 600-1200 px wide)."
182
+ " For detail, point `images:` in the yaml at tissue_hires_image.png or the full-resolution scan of each sample.")
183
+ return names
184
+
185
+
186
+ def _write_embedding(x: RExperiment, out: Path, *, assay: str, embedding: str | None, name: str, log: Log) -> tuple[str, str]:
187
+ key = embedding or pick_embedding(x.reduced_dims)
188
+ if key is None or key not in x.reduced_dims:
189
+ raise ValueError(f"no 2-D embedding found; reducedDims are: {', '.join(x.reduced_dims) or 'none'} (set `embedding:` on the entry)")
190
+ a = _anndata(x, assay, None)
191
+ a.obsm["X_umap"] = np.asarray(x.reduced_dims[key][:, :2], dtype=np.float32)
192
+ d = out / _safe(name)
193
+ d.mkdir(parents=True, exist_ok=True)
194
+ a.write_h5ad(d / "adata.h5ad")
195
+ log(f" {d.name}: {a.n_obs} cells, embedding {key!r} -> obsm/X_umap")
196
+ return d.name, key
197
+
198
+
199
+ def write_cache(entry: RObjectEntry, cache_root: Path, *, log: Log = print) -> dict:
200
+ """Convert one R object into `cache_root/<key>/` (written to a temporary folder first). Returns the index."""
201
+ key = cache_key(entry)
202
+ dest = Path(cache_root) / key
203
+ tmp = Path(cache_root) / f"{key}.tmp"
204
+ if tmp.exists():
205
+ shutil.rmtree(tmp)
206
+ tmp.mkdir(parents=True)
207
+ log(f"reading {entry.path} (the whole object is parsed; about as long as loading it in R)")
208
+ x = read_experiment(entry.path, object_name=entry.object)
209
+ spatial = x.class_name == "SpatialExperiment" and x.spatial is not None
210
+ what = f"{x.class_name} ({x.assay_name})" if x.assay_name else x.class_name
211
+ log(f" {what}: {x.n_vars} genes x {x.n_obs} cells, assays {', '.join(x.assays)}" + (f", {len(x.images)} image(s)" if x.images else ""))
212
+ for fs in x.feature_sets:
213
+ log(f" + {fs.name}: {len(fs.var):,} features as {fs.prefix!r}")
214
+ for note in x.notes:
215
+ log(f" note: {note}")
216
+ used_embedding = None
217
+ if spatial:
218
+ samples = _write_spatial(x, tmp, assay=entry.assay, sample_col=entry.sample_col, log=log)
219
+ else:
220
+ name, used_embedding = _write_embedding(x, tmp, assay=entry.assay, embedding=entry.embedding, name=entry.path.stem, log=log)
221
+ samples = [name]
222
+ st = entry.path.stat()
223
+ index = {
224
+ "source": str(entry.path),
225
+ "size": st.st_size,
226
+ "mtime_ns": st.st_mtime_ns,
227
+ "sample_col": entry.sample_col,
228
+ "assay": entry.assay,
229
+ "embedding": used_embedding,
230
+ "object": entry.object,
231
+ "class": x.class_name,
232
+ "spatial": spatial,
233
+ "assays": list(x.assays),
234
+ "feature_sets": [{"name": fs.name, "prefix": fs.prefix, "features": len(fs.var)} for fs in x.feature_sets],
235
+ "fragments": [f.path for f in x.fragments],
236
+ "samples": samples,
237
+ "cells": x.n_obs,
238
+ "converted": datetime.now(UTC).isoformat(timespec="seconds"),
239
+ "version": CONVERT_VERSION,
240
+ }
241
+ (tmp / INDEX).write_text(json.dumps(index, indent=1))
242
+ if dest.exists():
243
+ shutil.rmtree(dest)
244
+ tmp.rename(dest)
245
+ return index
246
+
247
+
248
+ def prune_cache(cache_root: Path, keep: set[str]) -> list[Path]:
249
+ """Remove cache folders no entry references any more (old keys, leftover temporary folders)."""
250
+ cache_root = Path(cache_root)
251
+ if not cache_root.is_dir():
252
+ return []
253
+ gone: list[Path] = []
254
+ for p in cache_root.iterdir():
255
+ if p.is_dir() and p.name not in keep:
256
+ shutil.rmtree(p)
257
+ gone.append(p)
258
+ return gone
259
+
260
+
261
+ def _glob_entry(entry: RObjectEntry, cache_dir: Path, index: dict, raw: dict) -> dict:
262
+ """The R entry rewritten as a glob over its cache folder, with defaults that depend on what the object is.
263
+ Those defaults sit below `defaults` / `platforms` / the entry itself, so anything the yaml says wins."""
264
+ e = dict(entry.template)
265
+ e["glob"] = str(cache_dir / "*" / "adata.h5ad")
266
+
267
+ def merged() -> dict:
268
+ return _merged_entry(e, raw)
269
+
270
+ if not index.get("spatial"):
271
+ if "platform" not in merged():
272
+ e["platform"] = "snrnaseq"
273
+ for k, v in (("kind", "embedding"), ("coords", "obsm/X_umap")):
274
+ if k not in merged():
275
+ e[k] = v
276
+ elif merged().get("platform") in ("xenium", "merfish") and "microns" not in merged():
277
+ e["microns"] = {"already_microns": True} # the cache's spot-spacing rule is for Visium only
278
+ return e
279
+
280
+
281
+ def expand_r_objects(cfg: DatasetConfig, cache_root: Path, *, convert: bool, log: Log = print) -> tuple[DatasetConfig, list[SourceStatus]]:
282
+ """Replace every R object entry by the samples in its cache, converting first when `convert` is set.
283
+ Without `convert` (a dry run) uncached objects are reported as pending and left out."""
284
+ if not cfg.r_objects:
285
+ return cfg, []
286
+ if cfg.path is None:
287
+ raise ValueError("R object entries need a config loaded from a file")
288
+ cache_root = Path(cache_root)
289
+ raw_samples: list[dict | None] = list(cfg.raw.get("samples") or [])
290
+ statuses: list[SourceStatus] = []
291
+ keep: set[str] = set()
292
+ for entry in cfg.r_objects:
293
+ if not entry.path.exists():
294
+ if convert:
295
+ raise FileNotFoundError(str(entry.path))
296
+ statuses.append(SourceStatus(entry, None, None, "missing"))
297
+ raw_samples[entry.index] = None
298
+ continue
299
+ key = cache_key(entry)
300
+ d = cache_root / key
301
+ index = read_index(d)
302
+ state = "cached"
303
+ if index is None:
304
+ if not convert:
305
+ statuses.append(SourceStatus(entry, key, d, "pending"))
306
+ raw_samples[entry.index] = None
307
+ continue
308
+ log(f"converting {entry.path} -> {d}")
309
+ index = write_cache(entry, cache_root, log=log)
310
+ state = "converted"
311
+ keep.add(key)
312
+ raw_samples[entry.index] = _glob_entry(entry, d, index, cfg.raw)
313
+ statuses.append(SourceStatus(entry, key, d, state, list(index.get("samples", []))))
314
+ if convert:
315
+ for p in prune_cache(cache_root, keep):
316
+ log(f"removed stale cache {p}")
317
+ new_raw = {**cfg.raw, "samples": [s for s in raw_samples if s is not None]}
318
+ new_cfg = config_from_raw(new_raw, cfg.path, original=cfg.raw)
319
+ new_cfg._r_objects = list(cfg.r_objects)
320
+ return new_cfg, statuses
321
+
@@ -0,0 +1,98 @@
1
+ """Automatic discovery: which obs columns to expose, and which images sit next to a sample."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import anndata as ad
10
+ import pandas as pd
11
+
12
+ from .config import FieldSpec, ImageSpec, MicronsSpec, SampleSpec
13
+
14
+ MAX_LEVELS = 200
15
+ SKIP_COLUMNS = re.compile(r"^(sample_id|sample|key|barcode|cell_id|cell|index|in_tissue|array_row|array_col|exclude_.*|discard.*|qc_filter|.*_1|.*_2)$", re.I)
16
+ NUMERIC_KEEP = re.compile(r"count|sum|detected|percent|ratio|area|score|umi|genes|mito|nucleus|transcript|total|ncount|nfeature", re.I)
17
+
18
+
19
+ def field_id(column: str) -> str:
20
+ fid = re.sub(r"[^A-Za-z0-9]+", "_", column).strip("_").lower()
21
+ return fid or "field"
22
+
23
+
24
+ def auto_fields(adata: ad.AnnData, sample: SampleSpec) -> dict[str, str]:
25
+ """field id -> obs column for every categorical (2..MAX_LEVELS levels) and QC-like numeric column."""
26
+ out: dict[str, str] = {}
27
+ n = adata.n_obs
28
+ for col in adata.obs.columns:
29
+ if SKIP_COLUMNS.match(str(col)):
30
+ continue
31
+ s = adata.obs[col]
32
+ if isinstance(s.dtype, pd.CategoricalDtype) or s.dtype == object or str(s.dtype) in ("string", "bool", "boolean"):
33
+ k = s.nunique(dropna=True)
34
+ if 2 <= k <= MAX_LEVELS and k < n:
35
+ out[field_id(str(col))] = str(col)
36
+ elif pd.api.types.is_numeric_dtype(s) and NUMERIC_KEEP.search(str(col)):
37
+ out[field_id(str(col))] = str(col)
38
+ return out
39
+
40
+
41
+ def infer_field_spec(fid: str, adata: ad.AnnData, column: str) -> FieldSpec:
42
+ s = adata.obs[column]
43
+ categorical = isinstance(s.dtype, pd.CategoricalDtype) or s.dtype == object or str(s.dtype) in ("string", "bool", "boolean")
44
+ name = re.sub(r"[_\.]+", " ", column).strip()
45
+ name = name[:1].upper() + name[1:]
46
+ return FieldSpec(id=fid, name=name, type="categorical" if categorical else "continuous")
47
+
48
+
49
+ def shared_image(png: Path, limit: int = 64) -> bool:
50
+ """Whether sibling sample folders (`../<other>/spatial/<same name>`) hold the very same file: one slide image that
51
+ several sections were cut from (two Visium HD sections on one slide, say). Such an image is masked to each
52
+ section's own cells, or the neighbour's tissue shows up under this sample with no cells on it."""
53
+ try:
54
+ here = png.resolve()
55
+ root = here.parent.parent.parent
56
+ size = here.stat().st_size
57
+ mine = None
58
+ for other in sorted(root.iterdir())[:limit]:
59
+ cand = other / here.parent.name / here.name
60
+ if other == here.parent.parent or not cand.is_file() or cand.stat().st_size != size:
61
+ continue
62
+ if mine is None:
63
+ mine = hashlib.sha1(here.read_bytes()).hexdigest()
64
+ if hashlib.sha1(cand.read_bytes()).hexdigest() == mine:
65
+ return True
66
+ except OSError:
67
+ return False
68
+ return False
69
+
70
+
71
+ def auto_images(sample: SampleSpec) -> tuple[list[ImageSpec], MicronsSpec | None]:
72
+ """Images next to the sample path. Returns (images, microns override or None)."""
73
+ d = Path(sample.path)
74
+ d = d if d.is_dir() and not (d / ".zattrs").exists() and not (d / "zarr.json").exists() else d.parent
75
+ if sample.kind != "spatial":
76
+ return [], None
77
+ # 1. OME-Zarr exported in the coordinate frame of the sample (level-0 pixels), e.g. image.ome.zarr
78
+ for cand in [d / "image.ome.zarr", *sorted(d.glob("*.ome.zarr"))]:
79
+ if cand.is_dir() and ((cand / ".zattrs").exists() or (cand / "zarr.json").exists()):
80
+ return [ImageSpec(id="he", name="H&E", path=cand, pixel_size="auto")], None
81
+ # 2. SpaceRanger layout: spatial/tissue_hires_image.png + scalefactors (coords are full-res pixels)
82
+ sp = d / "spatial"
83
+ sf = sp / "scalefactors_json.json"
84
+ if sf.exists():
85
+ scale = json.loads(sf.read_text())
86
+ # tissue_<id>_image.png with a tissue_<id>_scalef: hires first, then any other id, lowres last
87
+ pngs = sorted(sp.glob("tissue_*_image.png"), key=lambda q: (0 if "hires" in q.name else 2 if "lowres" in q.name else 1, q.name))
88
+ for png in pngs:
89
+ key = png.name.replace("tissue_", "tissue_", 1).replace("_image.png", "_scalef")
90
+ if key in scale:
91
+ spec = ImageSpec(id="he", name="H&E", path=png, pixel_size="auto", pixels_per_unit=float(scale[key]), mask=shared_image(png))
92
+ return [spec], MicronsSpec(scalefactors_json=sf)
93
+ if sf.exists(): # scale factors without an image (e.g. `convert` of an object whose image file was gone)
94
+ return [], MicronsSpec(scalefactors_json=sf)
95
+ # 3. Xenium morphology image
96
+ for tif in sorted(d.glob("morphology_focus*.ome.tif")) + sorted(d.glob("morphology*.ome.tif")):
97
+ return [ImageSpec(id="dapi", name="DAPI", path=tif, kind="multichannel", pixel_size="auto", pixels_per_unit=1 / 0.2125)], None
98
+ return [], None