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