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/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