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/validate.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Validate a bundle on disk or over HTTP (schema, shapes, codes, range requests)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.request
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .manifest import Manifest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _problem(problems: list[str], msg: str) -> None:
|
|
14
|
+
problems.append(msg)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_local(root: Path) -> list[str]:
|
|
18
|
+
import zarr
|
|
19
|
+
|
|
20
|
+
p: list[str] = []
|
|
21
|
+
root = Path(root)
|
|
22
|
+
mpath = root / "manifest.json"
|
|
23
|
+
if not mpath.exists():
|
|
24
|
+
return [f"missing {mpath}"]
|
|
25
|
+
try:
|
|
26
|
+
m = Manifest.model_validate_json(mpath.read_text())
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return [f"manifest.json invalid: {e}"]
|
|
29
|
+
genes_union = json.loads((root / "genes.json").read_text()) if (root / "genes.json").exists() else None
|
|
30
|
+
if genes_union is None:
|
|
31
|
+
_problem(p, "missing genes.json")
|
|
32
|
+
if m.defaultGene and genes_union is not None and m.defaultGene not in set(genes_union):
|
|
33
|
+
_problem(p, f"defaultGene {m.defaultGene!r} not in genes.json")
|
|
34
|
+
fpath = root / "features.json"
|
|
35
|
+
if fpath.exists():
|
|
36
|
+
try:
|
|
37
|
+
feats = json.loads(fpath.read_text())
|
|
38
|
+
assert isinstance(feats.get("genes"), list) and isinstance(feats.get("groups"), list)
|
|
39
|
+
for g in feats["groups"]:
|
|
40
|
+
assert all({"id", "label"} <= set(f) for f in g["features"])
|
|
41
|
+
except Exception as e:
|
|
42
|
+
_problem(p, f"features.json malformed: {e}")
|
|
43
|
+
field_ids = {f.id for f in m.fields}
|
|
44
|
+
for f in m.fields:
|
|
45
|
+
if f.type == "categorical" and f.vocabulary not in m.vocabularies:
|
|
46
|
+
_problem(p, f"field {f.id}: vocabulary {f.vocabulary!r} missing")
|
|
47
|
+
for v_id, v in m.vocabularies.items():
|
|
48
|
+
if len(v.categories) != len(v.colors):
|
|
49
|
+
_problem(p, f"vocabulary {v_id}: categories/colors length mismatch")
|
|
50
|
+
for sid in m.layout.order:
|
|
51
|
+
if sid not in {s.id for s in m.samples}:
|
|
52
|
+
_problem(p, f"layout.order references unknown sample {sid!r}")
|
|
53
|
+
for s in m.samples:
|
|
54
|
+
d = root / "samples" / s.id
|
|
55
|
+
if not d.is_dir():
|
|
56
|
+
_problem(p, f"{s.id}: missing sample directory")
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
g = zarr.open_group(str(d / "expr.zarr"), mode="r")
|
|
60
|
+
u8 = g["u8"]
|
|
61
|
+
if tuple(u8.shape) != (s.nGenes, s.nObs):
|
|
62
|
+
_problem(p, f"{s.id}: expr u8 shape {u8.shape} != ({s.nGenes}, {s.nObs})")
|
|
63
|
+
if u8.chunks[0] != 1:
|
|
64
|
+
_problem(p, f"{s.id}: expr u8 chunk shape {u8.chunks} is not gene-major (1, n_obs)")
|
|
65
|
+
if g["gmax"].shape[0] != s.nGenes:
|
|
66
|
+
_problem(p, f"{s.id}: gmax length mismatch")
|
|
67
|
+
if "gmin" in g and g["gmin"].shape[0] != s.nGenes:
|
|
68
|
+
_problem(p, f"{s.id}: gmin length mismatch")
|
|
69
|
+
gmax = np.asarray(g["gmax"][:])
|
|
70
|
+
if gmax.size and (not np.all(np.isfinite(gmax)) or (gmax <= 0).any()):
|
|
71
|
+
_problem(p, f"{s.id}: gmax has non-finite or non-positive values")
|
|
72
|
+
if s.nGenes:
|
|
73
|
+
_ = u8[0, :] # exercise one chunk read (and the shard index)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
_problem(p, f"{s.id}: expr.zarr unreadable: {e}")
|
|
76
|
+
genes = json.loads((d / "genes.json").read_text()) if (d / "genes.json").exists() else None
|
|
77
|
+
if genes is None or len(genes) != s.nGenes:
|
|
78
|
+
_problem(p, f"{s.id}: genes.json missing or length != nGenes")
|
|
79
|
+
try:
|
|
80
|
+
og = zarr.open_group(str(d / "obs.zarr"), mode="r")
|
|
81
|
+
if tuple(og["xy"].shape) != (s.nObs, 2):
|
|
82
|
+
_problem(p, f"{s.id}: obs xy shape {og['xy'].shape}")
|
|
83
|
+
if tuple(og["order"].shape) != (s.nObs,):
|
|
84
|
+
_problem(p, f"{s.id}: obs order shape")
|
|
85
|
+
elif s.nObs:
|
|
86
|
+
order = np.asarray(og["order"][:])
|
|
87
|
+
if order.min() != 0 or order.max() != s.nObs - 1 or np.unique(order).size != s.nObs:
|
|
88
|
+
_problem(p, f"{s.id}: obs order is not a permutation of the rows")
|
|
89
|
+
for fid in s.fields:
|
|
90
|
+
if fid not in field_ids:
|
|
91
|
+
_problem(p, f"{s.id}: field {fid!r} not declared in manifest.fields")
|
|
92
|
+
continue
|
|
93
|
+
f = m.field_by_id(fid)
|
|
94
|
+
if f.type == "categorical":
|
|
95
|
+
if fid not in og["cat"]:
|
|
96
|
+
_problem(p, f"{s.id}: obs.zarr/cat/{fid} missing")
|
|
97
|
+
continue
|
|
98
|
+
codes = og["cat"][fid][:]
|
|
99
|
+
ncat = len(m.vocabularies[f.vocabulary].categories)
|
|
100
|
+
if codes.shape != (s.nObs,):
|
|
101
|
+
_problem(p, f"{s.id}: obs.zarr/cat/{fid} has {codes.shape[0]} rows, expected {s.nObs}")
|
|
102
|
+
if codes.size and int(codes.max()) >= ncat:
|
|
103
|
+
_problem(p, f"{s.id}: field {fid} has code {int(codes.max())} >= {ncat} categories")
|
|
104
|
+
else:
|
|
105
|
+
if fid not in og["num"]:
|
|
106
|
+
_problem(p, f"{s.id}: obs.zarr/num/{fid} missing")
|
|
107
|
+
elif tuple(og["num"][fid].shape) != (s.nObs,):
|
|
108
|
+
_problem(p, f"{s.id}: obs.zarr/num/{fid} has {og['num'][fid].shape[0]} rows, expected {s.nObs}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
_problem(p, f"{s.id}: obs.zarr unreadable: {e}")
|
|
111
|
+
nblocks = -(-s.nObs // s.idBlock)
|
|
112
|
+
for b in range(nblocks):
|
|
113
|
+
fb = d / "ids" / f"{b}.json"
|
|
114
|
+
if not fb.exists():
|
|
115
|
+
_problem(p, f"{s.id}: ids/{b}.json missing")
|
|
116
|
+
break
|
|
117
|
+
want = min(s.idBlock, s.nObs - b * s.idBlock)
|
|
118
|
+
try:
|
|
119
|
+
got = len(json.loads(fb.read_text()))
|
|
120
|
+
except ValueError:
|
|
121
|
+
got = -1
|
|
122
|
+
if got != want:
|
|
123
|
+
_problem(p, f"{s.id}: ids/{b}.json has {got} ids, expected {want}")
|
|
124
|
+
break
|
|
125
|
+
for fid in s.outlines or []:
|
|
126
|
+
if not (d / "outlines" / f"{fid}.json").exists():
|
|
127
|
+
_problem(p, f"{s.id}: outlines/{fid}.json listed but missing")
|
|
128
|
+
if s.polygons:
|
|
129
|
+
off = d / "polygons.offsets.u32"
|
|
130
|
+
dl = d / "polygons.i16"
|
|
131
|
+
if not off.exists() or not dl.exists():
|
|
132
|
+
_problem(p, f"{s.id}: polygon files missing")
|
|
133
|
+
elif off.stat().st_size == 4 * (s.nObs + 1) and dl.stat().st_size == 4 * s.polygons.vertices:
|
|
134
|
+
offs = np.fromfile(off, dtype=np.uint32)
|
|
135
|
+
if offs[0] != 0 or offs[-1] != s.polygons.vertices or (np.diff(offs.astype(np.int64)) < 0).any():
|
|
136
|
+
_problem(p, f"{s.id}: polygon offsets are not a non-decreasing 0..vertices run")
|
|
137
|
+
else:
|
|
138
|
+
_problem(p, f"{s.id}: polygon file sizes do not match nObs/vertices")
|
|
139
|
+
for im in s.images:
|
|
140
|
+
try:
|
|
141
|
+
ig = zarr.open_group(str(d / im.path), mode="r")
|
|
142
|
+
lv0 = ig[ig.attrs["multiscales"][0]["datasets"][0]["path"]]
|
|
143
|
+
if tuple(lv0.shape[1:]) != tuple(im.size):
|
|
144
|
+
_problem(p, f"{s.id}: image {im.id} size {lv0.shape[1:]} != {im.size}")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
_problem(p, f"{s.id}: image {im.id} unreadable: {e}")
|
|
147
|
+
if not np.isfinite(s.bbox).all():
|
|
148
|
+
_problem(p, f"{s.id}: bbox not finite")
|
|
149
|
+
return p
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
ORIGIN = "https://viewer.example.org" # any browser origin; servers that gate CORS on it answer as they would for the viewer
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_remote(url: str) -> list[str]:
|
|
156
|
+
p: list[str] = []
|
|
157
|
+
url = url.rstrip("/")
|
|
158
|
+
try:
|
|
159
|
+
req = urllib.request.Request(url + "/manifest.json", headers={"Origin": ORIGIN})
|
|
160
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
161
|
+
m = Manifest.model_validate_json(r.read())
|
|
162
|
+
if not r.headers.get("Access-Control-Allow-Origin"):
|
|
163
|
+
_problem(p, "manifest.json: no Access-Control-Allow-Origin header; the browser will refuse to load the bundle (configure CORS on the host)")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
return [f"cannot fetch manifest: {e}"]
|
|
166
|
+
if not m.samples:
|
|
167
|
+
return p + ["manifest has no samples"]
|
|
168
|
+
s = m.samples[0]
|
|
169
|
+
sharded = s.expr.sharded
|
|
170
|
+
chunk = f"{url}/samples/{s.id}/expr.zarr/u8/c/0/0"
|
|
171
|
+
headers = {"Origin": ORIGIN}
|
|
172
|
+
if sharded:
|
|
173
|
+
headers["Range"] = "bytes=0-15"
|
|
174
|
+
req = urllib.request.Request(chunk, headers=headers)
|
|
175
|
+
try:
|
|
176
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
177
|
+
if sharded:
|
|
178
|
+
if r.status != 206:
|
|
179
|
+
_problem(p, f"range request returned {r.status}, expected 206: this bundle is sharded, so the host must support HTTP Range (or rebuild with --no-shard)")
|
|
180
|
+
exp = r.headers.get("Access-Control-Expose-Headers", "")
|
|
181
|
+
if "content-range" not in exp.lower() and "*" not in exp:
|
|
182
|
+
_problem(p, "Access-Control-Expose-Headers should include Content-Range for sharded bundles")
|
|
183
|
+
elif r.status != 200:
|
|
184
|
+
_problem(p, f"gene chunk request returned {r.status}")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
_problem(p, f"gene chunk request failed for {chunk}: {e}")
|
|
187
|
+
return p
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def validate(target: str) -> list[str]:
|
|
191
|
+
if target.startswith(("http://", "https://")):
|
|
192
|
+
return validate_remote(target)
|
|
193
|
+
return validate_local(Path(target))
|
spatialscape/vocab.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Dataset-level categorical vocabularies: stable codes + colors across samples and platforms."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import colorsys
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from .config import FieldSpec
|
|
13
|
+
from .manifest import Vocabulary
|
|
14
|
+
|
|
15
|
+
NA_LABEL = "NA"
|
|
16
|
+
NA_COLOR = "#c8c8c8"
|
|
17
|
+
|
|
18
|
+
# Tableau 20 + a few; stable, print-friendly
|
|
19
|
+
QUALITATIVE = [
|
|
20
|
+
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
|
|
21
|
+
"#bcbd22", "#17becf", "#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5", "#c49c94",
|
|
22
|
+
"#f7b6d2", "#dbdb8d", "#9edae5", "#393b79", "#637939", "#8c6d31", "#843c39", "#7b4173",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _golden_color(i: int) -> str:
|
|
27
|
+
h = (i * 0.618033988749895) % 1.0
|
|
28
|
+
s = 0.55 + 0.35 * ((i * 7) % 3) / 2
|
|
29
|
+
v = 0.75 + 0.2 * ((i * 11) % 2)
|
|
30
|
+
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
|
31
|
+
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_palette(path: Path | None) -> dict[str, dict[str, str]]:
|
|
35
|
+
if path is None:
|
|
36
|
+
return {}
|
|
37
|
+
with open(path) as fh:
|
|
38
|
+
raw: dict[str, Any] = json.load(fh)
|
|
39
|
+
out: dict[str, dict[str, str]] = {}
|
|
40
|
+
flat: dict[str, str] = {}
|
|
41
|
+
for k, v in raw.items():
|
|
42
|
+
if k.startswith("_"):
|
|
43
|
+
continue
|
|
44
|
+
if isinstance(v, dict):
|
|
45
|
+
out[k] = {str(a): str(b) for a, b in v.items()}
|
|
46
|
+
elif isinstance(v, str):
|
|
47
|
+
flat[k] = v
|
|
48
|
+
if flat:
|
|
49
|
+
out["_flat"] = flat
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
MAX_CATEGORIES = 65_535 # codes are uint16
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _label(c) -> str:
|
|
57
|
+
"""A category label as text: integral numbers without a trailing .0, so an int column in one sample and a
|
|
58
|
+
float column (NaN forced it) in another meet in the same category."""
|
|
59
|
+
if isinstance(c, (float, np.floating)):
|
|
60
|
+
return str(int(c)) if np.isfinite(c) and float(c).is_integer() else str(c)
|
|
61
|
+
return str(c)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _label_strings(s: pd.Series) -> pd.Series:
|
|
65
|
+
if pd.api.types.is_numeric_dtype(s.dtype) and not pd.api.types.is_bool_dtype(s.dtype):
|
|
66
|
+
v = pd.to_numeric(s, errors="coerce")
|
|
67
|
+
return v.map(lambda x: None if pd.isna(x) else _label(float(x))).astype("string")
|
|
68
|
+
return s.astype("string")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class VocabRegistry:
|
|
72
|
+
"""Append-only registry: codes never change once assigned, so a rebuilt sample stays consistent with the others."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
fields: list[FieldSpec],
|
|
77
|
+
palette: dict[str, dict[str, str]] | None = None,
|
|
78
|
+
existing: dict[str, Vocabulary] | None = None,
|
|
79
|
+
):
|
|
80
|
+
self.fields = {f.id: f for f in fields}
|
|
81
|
+
self.palette = palette or {}
|
|
82
|
+
self.vocabs: dict[str, dict[str, Any]] = {}
|
|
83
|
+
for vid, v in (existing or {}).items():
|
|
84
|
+
self.vocabs[vid] = {
|
|
85
|
+
"categories": list(v.categories),
|
|
86
|
+
"colors": list(v.colors),
|
|
87
|
+
"aliases": dict(v.aliases),
|
|
88
|
+
}
|
|
89
|
+
for f in fields:
|
|
90
|
+
if f.type != "categorical":
|
|
91
|
+
continue
|
|
92
|
+
vid = f.vocabulary or f.id
|
|
93
|
+
v = self.vocabs.setdefault(vid, {"categories": [], "colors": [], "aliases": {}})
|
|
94
|
+
v["aliases"].update(f.aliases)
|
|
95
|
+
v.setdefault("_palette_key", f.palette_key or vid)
|
|
96
|
+
for c in f.categories or []:
|
|
97
|
+
self._add(vid, c)
|
|
98
|
+
|
|
99
|
+
def declare(self, f: FieldSpec) -> None:
|
|
100
|
+
"""Register a field discovered at build time (idempotent)."""
|
|
101
|
+
if f.id in self.fields:
|
|
102
|
+
return
|
|
103
|
+
self.fields[f.id] = f
|
|
104
|
+
if f.type == "categorical":
|
|
105
|
+
vid = f.vocabulary or f.id
|
|
106
|
+
v = self.vocabs.setdefault(vid, {"categories": [], "colors": [], "aliases": {}})
|
|
107
|
+
v["aliases"].update(f.aliases)
|
|
108
|
+
v.setdefault("_palette_key", f.palette_key or vid)
|
|
109
|
+
|
|
110
|
+
def adopt_manifest_fields(self, manifest, only: set[str] | None = None) -> None:
|
|
111
|
+
"""Keep fields that an existing bundle already declares (e.g. auto-discovered ones); `only` restricts the ids."""
|
|
112
|
+
for mf in manifest.fields:
|
|
113
|
+
if mf.id in self.fields or (only is not None and mf.id not in only):
|
|
114
|
+
continue
|
|
115
|
+
if mf.type == "categorical":
|
|
116
|
+
self.declare(FieldSpec(id=mf.id, name=mf.name, type="categorical", vocabulary=mf.vocabulary, description=mf.description))
|
|
117
|
+
else:
|
|
118
|
+
self.declare(FieldSpec(id=mf.id, name=mf.name, type="continuous", range=mf.range, colormap=mf.colormap, description=mf.description))
|
|
119
|
+
|
|
120
|
+
def recolor_from_palette(self) -> int:
|
|
121
|
+
"""Re-derive the color of every existing label that the palette names, keeping codes. Returns how many changed."""
|
|
122
|
+
changed = 0
|
|
123
|
+
for vid, v in self.vocabs.items():
|
|
124
|
+
key = v.get("_palette_key", vid)
|
|
125
|
+
keys = [k.strip() for k in str(key).split(",")] + [vid, "_flat"]
|
|
126
|
+
for i, label in enumerate(v["categories"]):
|
|
127
|
+
if label == NA_LABEL:
|
|
128
|
+
continue
|
|
129
|
+
for pk in keys:
|
|
130
|
+
pal = self.palette.get(pk)
|
|
131
|
+
if pal and label in pal:
|
|
132
|
+
if v["colors"][i] != pal[label]:
|
|
133
|
+
v["colors"][i] = pal[label]
|
|
134
|
+
changed += 1
|
|
135
|
+
break
|
|
136
|
+
return changed
|
|
137
|
+
|
|
138
|
+
def vocab_id(self, field_id: str) -> str:
|
|
139
|
+
f = self.fields[field_id]
|
|
140
|
+
return f.vocabulary or f.id
|
|
141
|
+
|
|
142
|
+
def _color_for(self, vid: str, label: str, index: int) -> str:
|
|
143
|
+
if label == NA_LABEL:
|
|
144
|
+
return NA_COLOR
|
|
145
|
+
key = self.vocabs[vid].get("_palette_key", vid)
|
|
146
|
+
keys = [k.strip() for k in str(key).split(",")] + [vid, "_flat"]
|
|
147
|
+
for pk in keys:
|
|
148
|
+
pal = self.palette.get(pk)
|
|
149
|
+
if pal and label in pal:
|
|
150
|
+
return pal[label]
|
|
151
|
+
if index < len(QUALITATIVE):
|
|
152
|
+
return QUALITATIVE[index]
|
|
153
|
+
return _golden_color(index)
|
|
154
|
+
|
|
155
|
+
def _add(self, vid: str, label: str) -> int:
|
|
156
|
+
v = self.vocabs[vid]
|
|
157
|
+
cats = v["categories"]
|
|
158
|
+
if label in cats:
|
|
159
|
+
return cats.index(label)
|
|
160
|
+
if len(cats) >= MAX_CATEGORIES:
|
|
161
|
+
raise ValueError(f"vocabulary {vid!r}: more than {MAX_CATEGORIES:,} categories across samples")
|
|
162
|
+
cats.append(label)
|
|
163
|
+
v["colors"].append(self._color_for(vid, label, len(cats) - 1))
|
|
164
|
+
return len(cats) - 1
|
|
165
|
+
|
|
166
|
+
def encode(self, field_id: str, values: pd.Series) -> np.ndarray:
|
|
167
|
+
vid = self.vocab_id(field_id)
|
|
168
|
+
aliases = self.vocabs[vid]["aliases"]
|
|
169
|
+
s = values
|
|
170
|
+
if not isinstance(s.dtype, pd.CategoricalDtype):
|
|
171
|
+
s = _label_strings(s).astype("category")
|
|
172
|
+
labels = [aliases.get(_label(c), _label(c)) for c in s.cat.categories]
|
|
173
|
+
if len(labels) > MAX_CATEGORIES:
|
|
174
|
+
raise ValueError(f"field {field_id!r}: {len(labels):,} categories (at most {MAX_CATEGORIES:,}); map a coarser column, or leave this one out of `fields:`")
|
|
175
|
+
# preserve source category order for first-seen appends
|
|
176
|
+
code_map = np.empty(len(labels) + 1, dtype=np.uint16)
|
|
177
|
+
for i, lab in enumerate(labels):
|
|
178
|
+
code_map[i] = self._add(vid, lab)
|
|
179
|
+
code_map[-1] = self._add(vid, NA_LABEL) if (s.cat.codes < 0).any() else 0
|
|
180
|
+
src = s.cat.codes.to_numpy().astype(np.int64)
|
|
181
|
+
src[src < 0] = len(labels)
|
|
182
|
+
return code_map[src]
|
|
183
|
+
|
|
184
|
+
def to_manifest(self) -> dict[str, Vocabulary]:
|
|
185
|
+
return {
|
|
186
|
+
vid: Vocabulary(categories=v["categories"], colors=v["colors"], aliases=v["aliases"])
|
|
187
|
+
for vid, v in self.vocabs.items()
|
|
188
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spatialscape
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Build static spatialscape viewer bundles from AnnData / SpatialData
|
|
5
|
+
Project-URL: Homepage, https://mictott.github.io/spatialscape/docs/
|
|
6
|
+
Project-URL: Documentation, https://mictott.github.io/spatialscape/docs/
|
|
7
|
+
Project-URL: Repository, https://github.com/mictott/spatialscape
|
|
8
|
+
Project-URL: Issues, https://github.com/mictott/spatialscape/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/mictott/spatialscape/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Michael Totty <mictott@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: deck.gl,single-cell,spatial-transcriptomics,visium,visualization,xenium,zarr
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: anndata>=0.12
|
|
26
|
+
Requires-Dist: imageio>=2.34
|
|
27
|
+
Requires-Dist: numcodecs>=0.15
|
|
28
|
+
Requires-Dist: numpy>=1.26
|
|
29
|
+
Requires-Dist: pandas>=2.0
|
|
30
|
+
Requires-Dist: pillow>=10
|
|
31
|
+
Requires-Dist: pyarrow>=15
|
|
32
|
+
Requires-Dist: pydantic>=2.6
|
|
33
|
+
Requires-Dist: pyyaml>=6
|
|
34
|
+
Requires-Dist: scikit-image>=0.22
|
|
35
|
+
Requires-Dist: scipy>=1.11
|
|
36
|
+
Requires-Dist: tifffile>=2024.1
|
|
37
|
+
Requires-Dist: typer>=0.12
|
|
38
|
+
Requires-Dist: zarr>=3.0
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
41
|
+
Requires-Dist: h5py>=3.10; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
44
|
+
Provides-Extra: spatialdata
|
|
45
|
+
Requires-Dist: spatialdata>=0.4; extra == 'spatialdata'
|
|
46
|
+
Provides-Extra: tracks
|
|
47
|
+
Requires-Dist: pybigwig>=0.3.18; extra == 'tracks'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# spatialscape (Python)
|
|
51
|
+
|
|
52
|
+
The command-line tool behind **spatialscape**, a static, reactive browser for spatial transcriptomics and
|
|
53
|
+
snRNA-seq. It turns a saved R object (`SpatialExperiment` / `SingleCellExperiment` as `.rds` or `.rda`, parsed in
|
|
54
|
+
Python) or AnnData / SpatialData files into viewer bundles (sharded Zarr v3 expression, observation arrays in
|
|
55
|
+
microns, OME-Zarr images, domain outlines, cell polygons), and ships the viewer itself so you can open them locally
|
|
56
|
+
or publish them as a website.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install spatialscape
|
|
60
|
+
spatialscape init analysis/spe_dlpfc.rds --id dlpfc # writes dlpfc.yaml, a menu of what the data contains
|
|
61
|
+
# edit dlpfc.yaml: keep the annotations you want, name them, pick colors and the opening gene
|
|
62
|
+
spatialscape build dlpfc.yaml # -> bundles/dlpfc; rerun after any edit
|
|
63
|
+
spatialscape serve bundles --open # viewer + data at http://127.0.0.1:8787/
|
|
64
|
+
spatialscape site build bundles -o site # folder to upload to any static host
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`sscape` is a short alias for the same command. Documentation: https://mictott.github.io/spatialscape/docs/
|
|
68
|
+
Source and issues: https://github.com/MicTott/spatialscape
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
spatialscape/__init__.py,sha256=ZRSD3VYrKj5EZt0WP4kUKs7zjTXiCVyoPinNKnH7VpI,450
|
|
2
|
+
spatialscape/build.py,sha256=XU3z8n5oikH_iJBpB3kMx_TTIhZcTs3fKPLCDKZ-zP4,29398
|
|
3
|
+
spatialscape/cli.py,sha256=u-GW1A2cXuSiTpfGT-ki9LXMKbRFoyEDN_TyAAVW4EQ,21543
|
|
4
|
+
spatialscape/config.py,sha256=wUEys5KfIe11irlG_fYX0Gla1hUzQ-5Xed3-WWS4gTI,26648
|
|
5
|
+
spatialscape/convert.py,sha256=4ulCYNTDWQoo4TgZMko7aTRcURbwWi0kxhxpdxqzlfM,14389
|
|
6
|
+
spatialscape/discover.py,sha256=xgeTMMhdSUhocojxWQQB6cFf1BkOTb4pN0CC8edi47k,4909
|
|
7
|
+
spatialscape/expression.py,sha256=OpQjI-FX-mY6APn2JXm0xt3bqftQP1fmRPJMPg26JIw,14226
|
|
8
|
+
spatialscape/fingerprint.py,sha256=-MhR2NAYnLnulnVFR8gmuIzirlouXQlMrv4jIXW-VWw,6153
|
|
9
|
+
spatialscape/genes.py,sha256=LI0wBCi-k-Vf3j9ame5yR1cbyVy9OTw8SwCt3YRyBxI,11762
|
|
10
|
+
spatialscape/geometry.py,sha256=0C2HAHDzeDeOdndGCsw4--vZ5qvMcX6yI3GcclrKpXU,5109
|
|
11
|
+
spatialscape/images.py,sha256=n3oXkPL-9-iwioagmZMxir5VMcCfMipoyh8bD0HaFEs,9641
|
|
12
|
+
spatialscape/init.py,sha256=Rq8bgl_UIsspjwrMR3lAJcnuglWI-TYj1_lRijz9jAc,29110
|
|
13
|
+
spatialscape/manifest.py,sha256=QraZYehFtUK3fOxodm1Th-qrp0cvnCU17jt7x4bp4HU,5195
|
|
14
|
+
spatialscape/obs.py,sha256=ckB8lzVe5biO1AC5kz0eNu_o8h8SoNmXKrBrY3jcx64,1664
|
|
15
|
+
spatialscape/outlines.py,sha256=LqOc8pLo-2rbE86iEjJxqwwSG2x-8ltX7ZokRsdPuMY,5379
|
|
16
|
+
spatialscape/polygons.py,sha256=doniQ0g22M2P3dEbAlnMiPge8IpOQjRUpcP7SoPcXcc,3617
|
|
17
|
+
spatialscape/rds.py,sha256=ZpMQY0m_5PWeKvouSgbKU7BhTM4X6S321UMi9bKjqzo,27629
|
|
18
|
+
spatialscape/rdsread.py,sha256=CUjPh38jvh8hcIA020hFW2U0H_fbX_KK3LMrUBKA5vw,24195
|
|
19
|
+
spatialscape/readers.py,sha256=Ub9Myvr3IBoEu-TVrYtAgiuD6XpPvsr6dCpHy8wTDoQ,8243
|
|
20
|
+
spatialscape/registry.py,sha256=Z2p0T1Gysb0jF9Kt_8mvBUA4GKrqeaowMaWoPgMsS0U,3291
|
|
21
|
+
spatialscape/serve.py,sha256=zIX6PQkl3qgF_0JPAox_QNj4gR7u556io6nJFrvdeCg,11731
|
|
22
|
+
spatialscape/session.py,sha256=q-nY_CbQQV8R88UDJz315hjE1Fgy6cLCytx9sA5Q-yQ,22557
|
|
23
|
+
spatialscape/site.py,sha256=VPHUU6zKULj3p4Yf4K2HcbXiK2nZJ76IqvbXCfY5dxk,11434
|
|
24
|
+
spatialscape/synthetic.py,sha256=g0SkfUFUi0ht_EEKX1ON40JA5BtABYoIofHwlusc_rs,5836
|
|
25
|
+
spatialscape/thumbnail.py,sha256=ZwIyQH1wAUMzpfp4LKHVyUIojgb6vbAxFCcjDysza8o,3770
|
|
26
|
+
spatialscape/tracks.py,sha256=3qwrSL30f38b-Pc40ciHoHp6o8yBNjAuwPbVKqUExPE,26627
|
|
27
|
+
spatialscape/validate.py,sha256=qd8NxnesTYUzmhLbbN2zvlb2Np-SK62-7yLZWJny_90,9236
|
|
28
|
+
spatialscape/vocab.py,sha256=WX0p_loYTBv9WJef2VibZqrFgi6TbujH8fOzDDqtvv8,7538
|
|
29
|
+
spatialscape/_app/favicon.svg,sha256=EA9COdL3E7nL4g-GX8hfROaq9CcgNClWMEfiumqL3mY,8733
|
|
30
|
+
spatialscape/_app/index.html,sha256=-xoYf4F1VSFJyzAkmPtpuspnHbut3iW3CAvVDUsmTkE,1303
|
|
31
|
+
spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js,sha256=K1qf-eYyZ5eKD0dW1hULZe5SK8zsn0tr_HbvUueItX8,29590
|
|
32
|
+
spatialscape/_app/assets/blosc-DEOJs6D5.js,sha256=WK_5LTSrxQci60GRd06pq9ARX6hEfs0kkCuIDEGGuMo,603099
|
|
33
|
+
spatialscape/_app/assets/blosc-Df7TknHH.js,sha256=znsurYLuyNhY5ZtYiVoVm06F2emLZVt5Xyk4sGbGEw0,603115
|
|
34
|
+
spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js,sha256=eK-f7WqxPABozcGIuKFx0ybXjcFVY9sRdkIbQc76_r8,422
|
|
35
|
+
spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js,sha256=8B-3e6PgX5yK2HSSihnQRAFMoL6nzOhw_aVR7oeSSro,454
|
|
36
|
+
spatialscape/_app/assets/deflate-B2AbjNo_.js,sha256=reCDT2cAlS16SnlI9vxyytZFMqroFHaRbVP5C8mTmKU,218
|
|
37
|
+
spatialscape/_app/assets/expression-E359vKZW.js,sha256=22F9bTwAJW8Rxi7a4pkAMRYjXsRndxgC8JmBTOhE-BI,96648
|
|
38
|
+
spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js,sha256=b-bTC36ISROsZjQACh7goYIY_QcZBvk9CeUr_0rjfaI,19267
|
|
39
|
+
spatialscape/_app/assets/index-BKPnXJSt.css,sha256=3V6PZhFpUAlsG7n40JKAN_fPFkSov_CpF14Ut8TJi6E,19502
|
|
40
|
+
spatialscape/_app/assets/index-Gxpj1e1n.js,sha256=r974r8rfdlbs0B2_rQ5lm_YE23RwXMj8_fdide7LzQo,1482098
|
|
41
|
+
spatialscape/_app/assets/jpeg-b0MwBR_9.js,sha256=aXO7QO-HTsQPEimOQVmmm3IG6BoGNfA0oSLGJrMDQ10,8750
|
|
42
|
+
spatialscape/_app/assets/lerc-ujbbHc0C.js,sha256=oLHcKH64wvX5l1-72OSh9AQYUSzccqQOyKfV5oyD_8I,67574
|
|
43
|
+
spatialscape/_app/assets/lz4-BFJkNc3o.js,sha256=hkio-tdkKMBLlm1GqpBFSD3US9LOYqz1FouLcfG7ZQg,36277
|
|
44
|
+
spatialscape/_app/assets/lz4-DNE3IovK.js,sha256=zpFKo_9yyGNUoZPyqY4haGu8WkgxhFEVQM4d4McFCXE,36277
|
|
45
|
+
spatialscape/_app/assets/lzw-DfRkoFB0.js,sha256=asUiIAhvpZZ-arOIEpkjl27PgKZt-NQ22PL45T6zUp4,1401
|
|
46
|
+
spatialscape/_app/assets/packbits-BDCGuk_Z.js,sha256=-Y8AefaY1u8ENm9dsSrO22ze4Qkz1peZwcd4WT11d-s,387
|
|
47
|
+
spatialscape/_app/assets/pako.esm-Ch1S5qKo.js,sha256=FJH3XTGtsUOclbHjl5sXgzinn8_IbqzZWpgUExKFefc,46654
|
|
48
|
+
spatialscape/_app/assets/raw-vNZ-QFXZ.js,sha256=dR8oyPe673x6k89ROPUhLQ4NtovvBHIlLjDdUbxNY2o,150
|
|
49
|
+
spatialscape/_app/assets/webgl-D2Fkccwd.js,sha256=pBvvVDVvtb53QS4DTkYC3x1pRyqOSj014oIW0LsCY00,18883
|
|
50
|
+
spatialscape/_app/assets/webimage-DDU0IC05.js,sha256=WzTH1ij9pijApCv3webKJENFq5FL9sT9WSOPlWnem3Q,713
|
|
51
|
+
spatialscape/_app/assets/worker-CUy87eWB.js,sha256=AbSNcRT9CAnl2EIMfSPSgyYIHjV55_HS6VTfd42s0sc,121308
|
|
52
|
+
spatialscape/_app/assets/zstd-CGD_7wnU.js,sha256=jwzWBB4xi-zOFPt3KBVe8XNndOAb0FlLCJPYU2sNlZM,747108
|
|
53
|
+
spatialscape/_app/assets/zstd-qXhZ_1DN.js,sha256=1IC12lrledtmXSo_FoVC0K8IST7pU1DepwGGlyuNlJc,747108
|
|
54
|
+
spatialscape-0.3.0.dist-info/METADATA,sha256=ihFKQJuZH0NdLFsZ2Kf2Ej0Xgcep-YBFfnWT3jV26ww,3184
|
|
55
|
+
spatialscape-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
56
|
+
spatialscape-0.3.0.dist-info/entry_points.txt,sha256=7p3iXfRSX7AJh6h8gYOSOcyHUBwOCW22VtwaL5LirtY,86
|
|
57
|
+
spatialscape-0.3.0.dist-info/licenses/LICENSE,sha256=kQ6fUkaCdrMJA__sulW822y8lCn20JzRUFrCoUxSxg8,1070
|
|
58
|
+
spatialscape-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Totty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|