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/genes.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Gene models for the genome panel: one collapsed model per gene (the union of its exons), per chromosome.
|
|
2
|
+
|
|
3
|
+
Sources (`tracks.genes` in the yaml): a GTF (Cell Ranger, GENCODE, Ensembl), a UCSC genePred table such as
|
|
4
|
+
refGene.txt.gz, a BED (12 columns with blocks, or 3-6 columns of plain spans), or the keyword `refseq`, which
|
|
5
|
+
downloads the UCSC RefSeq table for the genome once into the user cache (~/.cache/spatialscape). Written as
|
|
6
|
+
|
|
7
|
+
tracks/genes/index.json {"source": ..., "chroms": {"chr1": ["DDX11L1", ...], ...}} names, for search
|
|
8
|
+
tracks/genes/<chrom>.json [[name, strand, start, end, [exon starts], [exon ends]], ...] sorted by start
|
|
9
|
+
|
|
10
|
+
Coordinates are 0-based half-open (BED style); strand is 1, -1, or 0 when unknown. The viewer loads the index
|
|
11
|
+
once and one chromosome at a time.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import urllib.request
|
|
20
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
Log = Callable[[str], None]
|
|
27
|
+
REFSEQ_URL = "https://hgdownload.soe.ucsc.edu/goldenPath/{genome}/database/refGene.txt.gz"
|
|
28
|
+
REFSEQ_GENOMES = {"hg38", "hg19", "mm10", "mm39", "rn6", "rn7", "danRer11", "dm6", "ce11", "sacCer3"} # UCSC assemblies with a refGene table
|
|
29
|
+
KEYWORDS = {"refseq", "none"}
|
|
30
|
+
STRAND = {"+": 1, "-": -1}
|
|
31
|
+
# (chrom, name, strand, unit, exon start, exon end), 0-based half-open; `unit` tells transcripts (or rows) apart
|
|
32
|
+
Record = tuple[str, str, int, object, int, int]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GeneModel:
|
|
37
|
+
name: str
|
|
38
|
+
chrom: str
|
|
39
|
+
strand: int
|
|
40
|
+
start: int
|
|
41
|
+
end: int
|
|
42
|
+
exon_starts: list[int] = field(default_factory=list)
|
|
43
|
+
exon_ends: list[int] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
def row(self) -> list:
|
|
46
|
+
return [self.name, self.strand, self.start, self.end, self.exon_starts, self.exon_ends]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cache_dir() -> Path:
|
|
50
|
+
root = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
51
|
+
return Path(root) / "spatialscape"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_chrom(chrom: str, sizes: Mapping[str, int]) -> str | None:
|
|
55
|
+
"""Map `1`, `chr1`, `MT` onto the names in chrom.sizes; None drops the record (alt contigs, scaffolds)."""
|
|
56
|
+
if chrom in sizes:
|
|
57
|
+
return chrom
|
|
58
|
+
if chrom.startswith("chr"):
|
|
59
|
+
if chrom[3:] in sizes:
|
|
60
|
+
return chrom[3:]
|
|
61
|
+
elif f"chr{chrom}" in sizes:
|
|
62
|
+
return f"chr{chrom}"
|
|
63
|
+
if chrom in ("MT", "M", "chrMT", "chrM"):
|
|
64
|
+
return next((alt for alt in ("chrM", "MT", "M", "chrMT") if alt in sizes), None)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def merge_intervals(starts: Iterable[int], ends: Iterable[int]) -> tuple[list[int], list[int]]:
|
|
69
|
+
out_s: list[int] = []
|
|
70
|
+
out_e: list[int] = []
|
|
71
|
+
for s, e in sorted(zip(starts, ends, strict=True)):
|
|
72
|
+
if out_e and s <= out_e[-1]:
|
|
73
|
+
out_e[-1] = max(out_e[-1], e)
|
|
74
|
+
else:
|
|
75
|
+
out_s.append(s)
|
|
76
|
+
out_e.append(e)
|
|
77
|
+
return out_s, out_e
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def collapse(records: Iterable[Record]) -> dict[str, list[GeneModel]]:
|
|
81
|
+
"""One model per locus of a gene name: transcripts (units) of the same name whose spans overlap are merged into
|
|
82
|
+
the union of their exons; copies elsewhere on the chromosome (the many U6 or Y_RNA genes) stay separate."""
|
|
83
|
+
acc: dict[tuple[str, str], dict[object, tuple[int, list[int], list[int]]]] = {}
|
|
84
|
+
for chrom, name, strand, unit, s, e in records:
|
|
85
|
+
units = acc.setdefault((chrom, name), {})
|
|
86
|
+
cur = units.get(unit)
|
|
87
|
+
if cur is None:
|
|
88
|
+
units[unit] = (strand, [s], [e])
|
|
89
|
+
else:
|
|
90
|
+
cur[1].append(s)
|
|
91
|
+
cur[2].append(e)
|
|
92
|
+
out: dict[str, list[GeneModel]] = {}
|
|
93
|
+
for (chrom, name), units in acc.items():
|
|
94
|
+
spans = sorted(((min(ss), max(ee), strand, ss, ee) for strand, ss, ee in units.values()), key=lambda t: (t[0], t[1]))
|
|
95
|
+
clusters: list[list[tuple[int, int, int, list[int], list[int]]]] = []
|
|
96
|
+
cluster_end = -1
|
|
97
|
+
for span in spans:
|
|
98
|
+
if clusters and span[0] <= cluster_end:
|
|
99
|
+
clusters[-1].append(span)
|
|
100
|
+
cluster_end = max(cluster_end, span[1])
|
|
101
|
+
else:
|
|
102
|
+
clusters.append([span])
|
|
103
|
+
cluster_end = span[1]
|
|
104
|
+
for cluster in clusters:
|
|
105
|
+
ms, me = merge_intervals([x for c in cluster for x in c[3]], [x for c in cluster for x in c[4]])
|
|
106
|
+
out.setdefault(chrom, []).append(GeneModel(name, chrom, cluster[0][2], ms[0], me[-1], ms, me))
|
|
107
|
+
for models in out.values():
|
|
108
|
+
models.sort(key=lambda g: (g.start, g.end, g.name))
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _chrom_map(chroms: Iterable[str], sizes: Mapping[str, int]) -> dict[str, str | None]:
|
|
113
|
+
return {c: normalize_chrom(c, sizes) for c in set(chroms)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_gtf(path: Path, sizes: Mapping[str, int]) -> dict[str, list[GeneModel]]:
|
|
117
|
+
"""Exon lines of a GTF, named by gene_name (gene_id when absent); streamed in chunks (GENCODE is ~3 M lines)."""
|
|
118
|
+
records: list[Record] = []
|
|
119
|
+
cols = ["chrom", "source", "feature", "start", "end", "score", "strand", "frame", "attrs"]
|
|
120
|
+
reader = pd.read_csv(
|
|
121
|
+
path, sep="\t", header=None, names=cols, usecols=["chrom", "feature", "start", "end", "strand", "attrs"],
|
|
122
|
+
dtype=str, quoting=csv.QUOTE_NONE, chunksize=500_000, na_filter=False,
|
|
123
|
+
)
|
|
124
|
+
for chunk in reader:
|
|
125
|
+
ex = chunk[chunk["feature"] == "exon"]
|
|
126
|
+
if ex.empty:
|
|
127
|
+
continue
|
|
128
|
+
cmap = _chrom_map(ex["chrom"].unique(), sizes)
|
|
129
|
+
chrom = ex["chrom"].map(cmap)
|
|
130
|
+
keep = chrom.notna()
|
|
131
|
+
if not keep.any():
|
|
132
|
+
continue
|
|
133
|
+
ex = ex[keep]
|
|
134
|
+
chrom = chrom[keep]
|
|
135
|
+
names = ex["attrs"].str.extract(r'gene_name "([^"]+)"', expand=False)
|
|
136
|
+
names = names.fillna(ex["attrs"].str.extract(r'gene_id "([^"]+)"', expand=False))
|
|
137
|
+
units = ex["attrs"].str.extract(r'transcript_id "([^"]+)"', expand=False).fillna(names)
|
|
138
|
+
ok = names.notna()
|
|
139
|
+
starts = ex["start"].astype("int64").to_numpy() - 1 # 1-based inclusive -> 0-based half-open
|
|
140
|
+
ends = ex["end"].astype("int64").to_numpy()
|
|
141
|
+
strands = ex["strand"].map(STRAND).fillna(0).astype(int).to_numpy()
|
|
142
|
+
for c, n, st, u, s, e, k in zip(chrom.to_numpy(), names.to_numpy(), strands, units.to_numpy(), starts, ends, ok.to_numpy(), strict=True):
|
|
143
|
+
if k:
|
|
144
|
+
records.append((c, n, int(st), u, int(s), int(e)))
|
|
145
|
+
return collapse(records)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read_genepred(path: Path, sizes: Mapping[str, int]) -> dict[str, list[GeneModel]]:
|
|
149
|
+
"""UCSC genePred / genePredExt, with or without the leading `bin` column of a table dump (refGene.txt.gz)."""
|
|
150
|
+
df = pd.read_csv(path, sep="\t", header=None, dtype=str, quoting=csv.QUOTE_NONE, na_filter=False, comment="#")
|
|
151
|
+
if df.shape[1] < 10:
|
|
152
|
+
raise ValueError(f"{path}: not a genePred table (expected at least 10 tab-separated columns)")
|
|
153
|
+
off = 1 if df.iloc[0, 3] in ("+", "-", ".") and df.shape[1] >= 11 else 0 # bin, name, chrom, strand, ...
|
|
154
|
+
name = df.iloc[:, off]
|
|
155
|
+
name2_col = off + 11
|
|
156
|
+
if df.shape[1] > name2_col:
|
|
157
|
+
name2 = df.iloc[:, name2_col]
|
|
158
|
+
name = name2.where(name2 != "", name)
|
|
159
|
+
chrom = df.iloc[:, off + 1].map(_chrom_map(df.iloc[:, off + 1].unique(), sizes))
|
|
160
|
+
strand = df.iloc[:, off + 2].map(STRAND).fillna(0).astype(int)
|
|
161
|
+
records: list[Record] = []
|
|
162
|
+
for i, (c, n, st, es, ee) in enumerate(zip(chrom, name, strand, df.iloc[:, off + 8], df.iloc[:, off + 9], strict=True)):
|
|
163
|
+
if c is None or not isinstance(c, str):
|
|
164
|
+
continue
|
|
165
|
+
for s, e in zip(es.strip(",").split(","), ee.strip(",").split(","), strict=True):
|
|
166
|
+
records.append((c, n, int(st), i, int(s), int(e)))
|
|
167
|
+
return collapse(records)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read_bed(path: Path, sizes: Mapping[str, int]) -> dict[str, list[GeneModel]]:
|
|
171
|
+
"""BED12 (blocks become exons) or plain BED3-6 spans (one block per gene, strand from column 6 when present)."""
|
|
172
|
+
df = pd.read_csv(path, sep="\t", header=None, dtype=str, quoting=csv.QUOTE_NONE, na_filter=False, comment="#")
|
|
173
|
+
ncol = df.shape[1]
|
|
174
|
+
chrom = df.iloc[:, 0].map(_chrom_map(df.iloc[:, 0].unique(), sizes))
|
|
175
|
+
records: list[Record] = []
|
|
176
|
+
for i, c in enumerate(chrom):
|
|
177
|
+
if not isinstance(c, str):
|
|
178
|
+
continue
|
|
179
|
+
s, e = int(df.iat[i, 1]), int(df.iat[i, 2])
|
|
180
|
+
n = df.iat[i, 3] if ncol > 3 and df.iat[i, 3] else f"{c}:{s + 1}-{e}"
|
|
181
|
+
st = STRAND.get(df.iat[i, 5], 0) if ncol > 5 else 0
|
|
182
|
+
if ncol >= 12:
|
|
183
|
+
bsz = [int(x) for x in df.iat[i, 10].strip(",").split(",") if x]
|
|
184
|
+
bst = [int(x) for x in df.iat[i, 11].strip(",").split(",") if x]
|
|
185
|
+
for a, b in zip(bst, bsz, strict=True):
|
|
186
|
+
records.append((c, n, st, i, s + a, s + a + b))
|
|
187
|
+
else:
|
|
188
|
+
records.append((c, n, st, i, s, e))
|
|
189
|
+
return collapse(records)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def read_gene_models(path: Path, sizes: Mapping[str, int]) -> dict[str, list[GeneModel]]:
|
|
193
|
+
"""Dispatch on the file name: .gtf, .bed, otherwise a genePred table (all optionally .gz)."""
|
|
194
|
+
name = Path(path).name.lower()
|
|
195
|
+
if name.endswith(".gz"):
|
|
196
|
+
name = name[:-3]
|
|
197
|
+
if name.endswith(".gtf"):
|
|
198
|
+
return _read_gtf(Path(path), sizes)
|
|
199
|
+
if name.endswith((".gff", ".gff3")):
|
|
200
|
+
raise ValueError(f"{path}: GFF3 gene models are not supported; convert to GTF first (for example `gffread -T`)")
|
|
201
|
+
if name.endswith(".bed"):
|
|
202
|
+
return _read_bed(Path(path), sizes)
|
|
203
|
+
return _read_genepred(Path(path), sizes)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _download(url: str, dest: Path) -> None:
|
|
207
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
209
|
+
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as fh:
|
|
210
|
+
shutil.copyfileobj(r, fh)
|
|
211
|
+
tmp.replace(dest)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def refseq_table(genome: str, *, log: Log = print, cache: Path | None = None) -> Path:
|
|
215
|
+
"""The UCSC refGene table for `genome`, downloaded once into the cache."""
|
|
216
|
+
if genome not in REFSEQ_GENOMES:
|
|
217
|
+
raise ValueError(f"no RefSeq gene table for genome {genome!r}; set `genes:` to a GTF, genePred or BED file")
|
|
218
|
+
dest = (cache or cache_dir()) / "genomes" / genome / "refGene.txt.gz"
|
|
219
|
+
if not dest.exists():
|
|
220
|
+
url = REFSEQ_URL.format(genome=genome)
|
|
221
|
+
log(f" tracks: downloading RefSeq gene models {url} -> {dest}")
|
|
222
|
+
_download(url, dest)
|
|
223
|
+
log(f" tracks: cached {dest.stat().st_size / 1e6:.1f} MB")
|
|
224
|
+
return dest
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def gene_models_for(genes: str | None, genome: str, sizes: Mapping[str, int], *, log: Log = print) -> tuple[dict[str, list[GeneModel]], str] | None:
|
|
228
|
+
"""Models and a source label for a `tracks.genes` setting, or None when there is nothing to draw."""
|
|
229
|
+
if genes is None or genes == "none":
|
|
230
|
+
return None
|
|
231
|
+
if genes == "refseq":
|
|
232
|
+
if genome not in REFSEQ_GENOMES:
|
|
233
|
+
log(f" tracks: no RefSeq gene table for genome {genome!r}; set `genes:` to a GTF, genePred or BED for a gene track")
|
|
234
|
+
return None
|
|
235
|
+
return read_gene_models(refseq_table(genome, log=log), sizes), f"RefSeq ({genome})"
|
|
236
|
+
path = Path(genes)
|
|
237
|
+
return read_gene_models(path, sizes), path.name
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_gene_models(models: Mapping[str, list[GeneModel]], out_dir: Path, *, source: str, order: Iterable[str] | None = None) -> int:
|
|
241
|
+
"""index.json plus one <chrom>.json per chromosome; returns the number of genes written."""
|
|
242
|
+
out_dir = Path(out_dir)
|
|
243
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
chroms = [c for c in (order or models) if c in models] if order else list(models)
|
|
245
|
+
index = {"source": source, "chroms": {c: [g.name for g in models[c]] for c in chroms}}
|
|
246
|
+
(out_dir / "index.json").write_text(json.dumps(index, separators=(",", ":")))
|
|
247
|
+
n = 0
|
|
248
|
+
for c in chroms:
|
|
249
|
+
(out_dir / f"{c}.json").write_text(json.dumps([g.row() for g in models[c]], separators=(",", ":")))
|
|
250
|
+
n += len(models[c])
|
|
251
|
+
return n
|
spatialscape/geometry.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Coordinate frames: source units -> microns, flips/rotations shared by points and images."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .config import SampleSpec
|
|
10
|
+
from .readers import SampleInput
|
|
11
|
+
|
|
12
|
+
VISIUM_SPOT_DIAMETER_UM = 55.0
|
|
13
|
+
VISIUM_SPOT_SPACING_UM = 100.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _scalar(v: Any) -> float:
|
|
17
|
+
return float(np.asarray(v).ravel()[0])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _no_spot_rule_for_hd(spec: SampleSpec, sf: dict[str, Any]) -> None:
|
|
21
|
+
"""The 55 um spot / 100 um spacing rule is Visium geometry; applied to Visium HD bins it inflates the scale
|
|
22
|
+
by the bin pitch / 100 um, silently. HD needs SpaceRanger's microns_per_pixel."""
|
|
23
|
+
if spec.platform == "visium_hd" and "microns_per_pixel" not in sf:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"sample {spec.id}: Visium HD cannot use the Visium spot-spacing rule; set `microns: {{ microns_per_unit: <SpaceRanger microns_per_pixel> }}`"
|
|
26
|
+
" (bin coordinates in full-resolution pixels) or `already_microns: true`"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _from_scalefactors(sf: dict[str, Any]) -> float | None:
|
|
31
|
+
if "microns_per_pixel" in sf:
|
|
32
|
+
return float(sf["microns_per_pixel"])
|
|
33
|
+
if "spot_diameter_fullres" in sf:
|
|
34
|
+
return VISIUM_SPOT_DIAMETER_UM / float(sf["spot_diameter_fullres"])
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def microns_per_unit(spec: SampleSpec, sinput: SampleInput) -> float:
|
|
39
|
+
"""Microns per coordinate unit of `sinput.coords`."""
|
|
40
|
+
m = spec.microns
|
|
41
|
+
if m.already_microns:
|
|
42
|
+
return 1.0
|
|
43
|
+
if m.microns_per_unit is not None:
|
|
44
|
+
return float(m.microns_per_unit)
|
|
45
|
+
if m.spot_diameter_fullres is not None:
|
|
46
|
+
return VISIUM_SPOT_DIAMETER_UM / float(m.spot_diameter_fullres)
|
|
47
|
+
if m.spot_spacing is not None:
|
|
48
|
+
return VISIUM_SPOT_SPACING_UM / float(m.spot_spacing)
|
|
49
|
+
if m.scalefactors_json is not None:
|
|
50
|
+
with open(m.scalefactors_json) as fh:
|
|
51
|
+
sf = json.load(fh)
|
|
52
|
+
_no_spot_rule_for_hd(spec, sf)
|
|
53
|
+
v = _from_scalefactors(sf)
|
|
54
|
+
if v is None:
|
|
55
|
+
raise ValueError(f"{m.scalefactors_json}: no microns_per_pixel / spot_diameter_fullres")
|
|
56
|
+
return v
|
|
57
|
+
# auto
|
|
58
|
+
if spec.kind == "embedding":
|
|
59
|
+
ext = np.ptp(sinput.coords, axis=0).max()
|
|
60
|
+
return float(spec.extent / ext) if ext > 0 else 1.0
|
|
61
|
+
if spec.platform in ("xenium", "merfish"):
|
|
62
|
+
return 1.0
|
|
63
|
+
uns = sinput.uns
|
|
64
|
+
if "spot_nn_spacing_level0_px" in uns:
|
|
65
|
+
return VISIUM_SPOT_SPACING_UM / _scalar(uns["spot_nn_spacing_level0_px"])
|
|
66
|
+
spatial = uns.get("spatial")
|
|
67
|
+
if isinstance(spatial, dict):
|
|
68
|
+
for lib in spatial.values():
|
|
69
|
+
if isinstance(lib, dict) and isinstance(lib.get("scalefactors"), dict):
|
|
70
|
+
_no_spot_rule_for_hd(spec, lib["scalefactors"])
|
|
71
|
+
v = _from_scalefactors(lib["scalefactors"])
|
|
72
|
+
if v is not None:
|
|
73
|
+
return v
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"sample {spec.id}: cannot infer microns per coordinate unit; set `microns:` "
|
|
76
|
+
"(already_microns | microns_per_unit | spot_diameter_fullres | spot_spacing | scalefactors_json). "
|
|
77
|
+
"Coordinates already in microns (Xenium, MERFISH, most segmentation outputs): `platform: xenium` / `merfish`, "
|
|
78
|
+
"or `microns: { already_microns: true }`; Visium HD: `microns: { microns_per_unit: <SpaceRanger microns_per_pixel> }`"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def default_point_radius(spec: SampleSpec, xy_um: np.ndarray) -> float:
|
|
83
|
+
if spec.point_radius is not None:
|
|
84
|
+
return float(spec.point_radius)
|
|
85
|
+
if spec.kind == "embedding":
|
|
86
|
+
return float(0.004 * np.ptp(xy_um, axis=0).max())
|
|
87
|
+
if spec.platform == "visium":
|
|
88
|
+
return VISIUM_SPOT_DIAMETER_UM / 2
|
|
89
|
+
if spec.platform == "visium_hd":
|
|
90
|
+
return max(1.0, 0.5 * _median_nn_distance(xy_um))
|
|
91
|
+
return 5.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _median_nn_distance(xy: np.ndarray, sample: int = 2000, seed: int = 0) -> float:
|
|
95
|
+
from scipy.spatial import cKDTree
|
|
96
|
+
|
|
97
|
+
rng = np.random.default_rng(seed)
|
|
98
|
+
idx = rng.choice(len(xy), size=min(sample, len(xy)), replace=False)
|
|
99
|
+
tree = cKDTree(xy)
|
|
100
|
+
d, _ = tree.query(xy[idx], k=2)
|
|
101
|
+
return float(np.median(d[:, 1]))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def transform_points(xy: np.ndarray, width: float, height: float, flip: str, rotate: int) -> np.ndarray:
|
|
105
|
+
"""Apply flip then rotate in a frame of size (width, height). Mirrors transform_image."""
|
|
106
|
+
x, y = xy[:, 0].copy(), xy[:, 1].copy()
|
|
107
|
+
w, h = float(width), float(height)
|
|
108
|
+
if flip == "x":
|
|
109
|
+
x = w - x
|
|
110
|
+
elif flip == "y":
|
|
111
|
+
y = h - y
|
|
112
|
+
if rotate == 90: # counter-clockwise, like np.rot90(k=1) on a (y, x) image
|
|
113
|
+
x, y = y, w - x
|
|
114
|
+
w, h = h, w
|
|
115
|
+
elif rotate == 180:
|
|
116
|
+
x, y = w - x, h - y
|
|
117
|
+
elif rotate == 270:
|
|
118
|
+
x, y = h - y, x
|
|
119
|
+
w, h = h, w
|
|
120
|
+
return np.stack([x, y], axis=1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def transform_image(img: np.ndarray, flip: str, rotate: int) -> np.ndarray:
|
|
124
|
+
"""img is (c, y, x). Same geometric operation as transform_points."""
|
|
125
|
+
if flip == "x":
|
|
126
|
+
img = img[:, :, ::-1]
|
|
127
|
+
elif flip == "y":
|
|
128
|
+
img = img[:, ::-1, :]
|
|
129
|
+
k = {0: 0, 90: 1, 180: 2, 270: 3}[rotate]
|
|
130
|
+
if k:
|
|
131
|
+
img = np.rot90(img, k=k, axes=(1, 2))
|
|
132
|
+
return np.ascontiguousarray(img)
|
spatialscape/images.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Read H&E / fluorescence images and write OME-Zarr (NGFF 0.4, zarr v2) pyramids Viv can read."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import zarr
|
|
9
|
+
from numcodecs import Blosc
|
|
10
|
+
|
|
11
|
+
from .geometry import transform_image
|
|
12
|
+
|
|
13
|
+
TILE = 512
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_image(path: Path, *, rgb: bool = True, max_size: int | None = None) -> np.ndarray:
|
|
17
|
+
"""Return (c, y, x). Supports OME-Zarr dirs, tiff, png/jpg. With `max_size`, a pyramidal source (OME-Zarr
|
|
18
|
+
multiscales, pyramidal/OME-TIFF) is read at the smallest level that is still at least that large, so a 40x
|
|
19
|
+
scan is never decoded in full just to be shrunk."""
|
|
20
|
+
path = Path(path)
|
|
21
|
+
if path.is_dir():
|
|
22
|
+
return _read_ome_zarr(path, max_size=max_size)
|
|
23
|
+
suf = path.suffix.lower()
|
|
24
|
+
if suf in (".tif", ".tiff", ".btf"):
|
|
25
|
+
import tifffile
|
|
26
|
+
|
|
27
|
+
with tifffile.TiffFile(str(path)) as tf:
|
|
28
|
+
series = tf.series[0]
|
|
29
|
+
level = _pick_level([tuple(lv.shape) for lv in series.levels], series.axes, max_size) if max_size and len(series.levels) > 1 else 0
|
|
30
|
+
arr = np.asarray(series.levels[level].asarray()) if level else np.asarray(series.asarray())
|
|
31
|
+
else:
|
|
32
|
+
import imageio.v3 as iio
|
|
33
|
+
|
|
34
|
+
arr = np.asarray(iio.imread(path))
|
|
35
|
+
return _to_cyx(arr, rgb=rgb)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pick_level(shapes: list[tuple[int, ...]], axes: str | list[str], max_size: int | None) -> int:
|
|
39
|
+
"""The last pyramid level whose longer side is still >= max_size (finer levels add nothing after limit_size)."""
|
|
40
|
+
if not max_size:
|
|
41
|
+
return 0
|
|
42
|
+
axes = [a.lower() for a in axes]
|
|
43
|
+
yi = axes.index("y") if "y" in axes else -2
|
|
44
|
+
xi = axes.index("x") if "x" in axes else -1
|
|
45
|
+
pick = 0
|
|
46
|
+
for i, shape in enumerate(shapes):
|
|
47
|
+
if max(shape[yi], shape[xi]) >= max_size:
|
|
48
|
+
pick = i
|
|
49
|
+
else:
|
|
50
|
+
break
|
|
51
|
+
return pick
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _to_cyx(arr: np.ndarray, *, rgb: bool = True) -> np.ndarray:
|
|
55
|
+
"""(c, y, x). A 4th plane is alpha only for an RGB image; a 4-channel fluorescence stack keeps all four."""
|
|
56
|
+
arr = np.squeeze(arr)
|
|
57
|
+
if arr.ndim == 2:
|
|
58
|
+
return arr[None, :, :]
|
|
59
|
+
if arr.ndim == 3:
|
|
60
|
+
if arr.shape[-1] in (3, 4) and arr.shape[0] not in (3, 4):
|
|
61
|
+
arr = np.moveaxis(arr, -1, 0)
|
|
62
|
+
if arr.shape[0] == 4 and rgb:
|
|
63
|
+
arr = arr[:3]
|
|
64
|
+
return np.ascontiguousarray(arr)
|
|
65
|
+
raise ValueError(f"cannot interpret image with shape {arr.shape}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_ome_zarr(path: Path, *, max_size: int | None = None) -> np.ndarray:
|
|
69
|
+
g = zarr.open_group(str(path), mode="r")
|
|
70
|
+
attrs = dict(g.attrs)
|
|
71
|
+
ms = attrs.get("multiscales") or attrs.get("ome", {}).get("multiscales")
|
|
72
|
+
if not ms:
|
|
73
|
+
raise ValueError(f"{path}: no multiscales metadata")
|
|
74
|
+
axes = [a["name"] if isinstance(a, dict) else a for a in ms[0].get("axes", [])]
|
|
75
|
+
paths = [d["path"] for d in ms[0]["datasets"]]
|
|
76
|
+
level = _pick_level([tuple(g[p].shape) for p in paths], axes or ["c", "y", "x"][-len(g[paths[0]].shape) :], max_size) if max_size else 0
|
|
77
|
+
arr = np.asarray(g[paths[level]][:])
|
|
78
|
+
if axes:
|
|
79
|
+
# drop t/z if singleton, then order to c,y,x
|
|
80
|
+
order = []
|
|
81
|
+
for want in ("c", "y", "x"):
|
|
82
|
+
if want in axes:
|
|
83
|
+
order.append(axes.index(want))
|
|
84
|
+
keep = [i for i, a in enumerate(axes) if a in ("c", "y", "x")]
|
|
85
|
+
drop = [i for i in range(arr.ndim) if i not in keep]
|
|
86
|
+
for i in sorted(drop, reverse=True):
|
|
87
|
+
if arr.shape[i] != 1:
|
|
88
|
+
raise ValueError(f"{path}: non-singleton axis {axes[i]} unsupported")
|
|
89
|
+
arr = np.take(arr, 0, axis=i)
|
|
90
|
+
axes2 = [axes[i] for i in keep]
|
|
91
|
+
arr = np.transpose(arr, [axes2.index(a) for a in ("c", "y", "x") if a in axes2])
|
|
92
|
+
return _to_cyx(arr)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def downsample2(img: np.ndarray, strip: int = 1024) -> np.ndarray:
|
|
96
|
+
"""(c, y, x) -> 2x mean downsample, keeping dtype. Integer images are summed in uint32 strip by strip, so the
|
|
97
|
+
working memory is a slice of the output, not a float32 copy of the whole image."""
|
|
98
|
+
c, h, w = img.shape
|
|
99
|
+
h2, w2 = h // 2, w // 2
|
|
100
|
+
if h2 == 0 or w2 == 0:
|
|
101
|
+
return img
|
|
102
|
+
out = np.empty((c, h2, w2), dtype=img.dtype)
|
|
103
|
+
integer = np.issubdtype(img.dtype, np.integer)
|
|
104
|
+
for y in range(0, h2, strip):
|
|
105
|
+
y1 = min(h2, y + strip)
|
|
106
|
+
blk = img[:, 2 * y : 2 * y1, : 2 * w2]
|
|
107
|
+
if integer:
|
|
108
|
+
acc = blk[:, 0::2, 0::2].astype(np.uint32)
|
|
109
|
+
acc += blk[:, 1::2, 0::2]
|
|
110
|
+
acc += blk[:, 0::2, 1::2]
|
|
111
|
+
acc += blk[:, 1::2, 1::2]
|
|
112
|
+
out[:, y:y1] = ((acc + 2) >> 2).astype(img.dtype)
|
|
113
|
+
else:
|
|
114
|
+
out[:, y:y1] = blk.astype(np.float32).reshape(c, y1 - y, 2, w2, 2).mean(axis=(2, 4)).astype(img.dtype)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def limit_size(img: np.ndarray, max_size: int | None) -> np.ndarray:
|
|
119
|
+
if not max_size:
|
|
120
|
+
return img
|
|
121
|
+
while max(img.shape[1], img.shape[2]) > max_size:
|
|
122
|
+
img = downsample2(img)
|
|
123
|
+
return img
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def write_ome_zarr(
|
|
127
|
+
img: np.ndarray,
|
|
128
|
+
out_path: Path,
|
|
129
|
+
*,
|
|
130
|
+
rgb: bool,
|
|
131
|
+
channel_names: list[str] | None = None,
|
|
132
|
+
channel_colors: list[str] | None = None,
|
|
133
|
+
windows: list[tuple[float, float]] | None = None,
|
|
134
|
+
min_level_px: int = 256,
|
|
135
|
+
) -> list[tuple[int, int, int]]:
|
|
136
|
+
"""Write NGFF 0.4 (zarr v2) with relative pyramid scales; returns level shapes."""
|
|
137
|
+
c = img.shape[0]
|
|
138
|
+
levels = [img]
|
|
139
|
+
while max(levels[-1].shape[1], levels[-1].shape[2]) > min_level_px:
|
|
140
|
+
nxt = downsample2(levels[-1])
|
|
141
|
+
if nxt.shape == levels[-1].shape:
|
|
142
|
+
break
|
|
143
|
+
levels.append(nxt)
|
|
144
|
+
store = zarr.storage.LocalStore(str(out_path))
|
|
145
|
+
root = zarr.open_group(store, mode="w", zarr_format=2)
|
|
146
|
+
comp = Blosc(cname="zstd", clevel=5, shuffle=Blosc.SHUFFLE)
|
|
147
|
+
datasets = []
|
|
148
|
+
for i, lv in enumerate(levels):
|
|
149
|
+
arr = root.create_array(
|
|
150
|
+
str(i),
|
|
151
|
+
shape=lv.shape,
|
|
152
|
+
chunks=(1, min(TILE, lv.shape[1]), min(TILE, lv.shape[2])),
|
|
153
|
+
dtype=lv.dtype,
|
|
154
|
+
compressors=comp,
|
|
155
|
+
fill_value=0,
|
|
156
|
+
)
|
|
157
|
+
arr[:] = lv
|
|
158
|
+
datasets.append({"path": str(i), "coordinateTransformations": [{"type": "scale", "scale": [1, 2**i, 2**i]}]})
|
|
159
|
+
names = channel_names or ([f"{k}" for k in ("R", "G", "B")] if rgb else [f"ch{k}" for k in range(c)])
|
|
160
|
+
colors = channel_colors or (["ff0000", "00ff00", "0000ff"] if rgb else ["ffffff"] * c)
|
|
161
|
+
maxv = 255 if img.dtype == np.uint8 else int(np.iinfo(img.dtype).max) if np.issubdtype(img.dtype, np.integer) else 1
|
|
162
|
+
wins = windows or [(0, maxv)] * c
|
|
163
|
+
root.attrs.update(
|
|
164
|
+
{
|
|
165
|
+
"multiscales": [
|
|
166
|
+
{
|
|
167
|
+
"version": "0.4",
|
|
168
|
+
"name": out_path.stem,
|
|
169
|
+
"axes": [
|
|
170
|
+
{"name": "c", "type": "channel"},
|
|
171
|
+
{"name": "y", "type": "space"},
|
|
172
|
+
{"name": "x", "type": "space"},
|
|
173
|
+
],
|
|
174
|
+
"datasets": datasets,
|
|
175
|
+
"type": "mean",
|
|
176
|
+
}
|
|
177
|
+
],
|
|
178
|
+
"omero": {
|
|
179
|
+
"id": 1,
|
|
180
|
+
"name": out_path.stem,
|
|
181
|
+
"version": "0.4",
|
|
182
|
+
"rdefs": {"model": "color" if rgb else "greyscale", "defaultT": 0, "defaultZ": 0},
|
|
183
|
+
"channels": [
|
|
184
|
+
{
|
|
185
|
+
"label": names[k],
|
|
186
|
+
"color": colors[k].lstrip("#"),
|
|
187
|
+
"active": True,
|
|
188
|
+
"window": {"min": 0, "max": maxv, "start": wins[k][0], "end": wins[k][1]},
|
|
189
|
+
}
|
|
190
|
+
for k in range(c)
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
return [tuple(lv.shape) for lv in levels]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def prepare_image(path: Path, flip: str, rotate: int, max_size: int | None, *, rgb: bool = True) -> np.ndarray:
|
|
199
|
+
img = read_image(path, rgb=rgb, max_size=max_size)
|
|
200
|
+
img = limit_size(img, max_size)
|
|
201
|
+
return transform_image(img, flip, rotate)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def read_image_shape(path: Path, *, rgb: bool = True) -> tuple[int, int, int]:
|
|
205
|
+
"""(c, y, x) at level 0 from the file header: TIFF series metadata, PNG/JPEG headers, OME-Zarr attributes."""
|
|
206
|
+
path = Path(path)
|
|
207
|
+
suffix = path.suffix.lower()
|
|
208
|
+
if suffix in (".tif", ".tiff"):
|
|
209
|
+
import tifffile
|
|
210
|
+
|
|
211
|
+
with tifffile.TiffFile(str(path)) as tf:
|
|
212
|
+
series = tf.series[0]
|
|
213
|
+
shape, axes = tuple(series.shape), str(series.axes)
|
|
214
|
+
if {"Y", "X"} <= set(axes):
|
|
215
|
+
c = shape[axes.index("C")] if "C" in axes else shape[axes.index("S")] if "S" in axes else 1
|
|
216
|
+
return (3 if c == 4 and rgb else c, shape[axes.index("Y")], shape[axes.index("X")])
|
|
217
|
+
return _to_cyx(np.zeros(shape, dtype=np.uint8), rgb=rgb).shape
|
|
218
|
+
if suffix in (".png", ".jpg", ".jpeg"):
|
|
219
|
+
from PIL import Image
|
|
220
|
+
|
|
221
|
+
with Image.open(path) as im:
|
|
222
|
+
w, h = im.size
|
|
223
|
+
c = len(im.getbands())
|
|
224
|
+
return (3 if c == 4 and rgb else c, h, w)
|
|
225
|
+
if path.is_dir():
|
|
226
|
+
g = zarr.open_group(str(path), mode="r")
|
|
227
|
+
attrs = dict(g.attrs)
|
|
228
|
+
ms = attrs.get("multiscales") or attrs.get("ome", {}).get("multiscales")
|
|
229
|
+
shape = tuple(g[ms[0]["datasets"][0]["path"]].shape)
|
|
230
|
+
axes = [a["name"] if isinstance(a, dict) else a for a in ms[0].get("axes", [])]
|
|
231
|
+
if axes and set(axes) >= {"y", "x"}:
|
|
232
|
+
c = shape[axes.index("c")] if "c" in axes else 1
|
|
233
|
+
return (c, shape[axes.index("y")], shape[axes.index("x")])
|
|
234
|
+
return _to_cyx(np.zeros(shape, dtype=np.uint8), rgb=rgb).shape
|
|
235
|
+
return read_image(path, rgb=rgb).shape
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def json_dump(obj, path: Path) -> None:
|
|
239
|
+
with open(path, "w") as fh:
|
|
240
|
+
json.dump(obj, fh, indent=1)
|