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/manifest.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Output schema: manifest.json written into every bundle.
|
|
2
|
+
|
|
3
|
+
Field names are camelCase on purpose so the JSON matches the TypeScript types in
|
|
4
|
+
app/src/data/manifest.ts one-to-one.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
FORMAT_VERSION = 1
|
|
13
|
+
|
|
14
|
+
Platform = Literal["visium", "visium_hd", "xenium", "merfish", "snrnaseq", "other"]
|
|
15
|
+
SampleKind = Literal["spatial", "embedding"]
|
|
16
|
+
LayoutMode = Literal["grid", "strip"]
|
|
17
|
+
BUILTIN_COLORMAPS = ["viridis", "magma", "inferno", "plasma", "cividis", "turbo", "greys", "rdbu"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Model(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Vocabulary(_Model):
|
|
25
|
+
categories: list[str]
|
|
26
|
+
colors: list[str]
|
|
27
|
+
aliases: dict[str, str] = Field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CategoricalField(_Model):
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
type: Literal["categorical"] = "categorical"
|
|
34
|
+
vocabulary: str
|
|
35
|
+
description: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContinuousField(_Model):
|
|
39
|
+
id: str
|
|
40
|
+
name: str
|
|
41
|
+
type: Literal["continuous"] = "continuous"
|
|
42
|
+
range: tuple[float, float] | None = None
|
|
43
|
+
colormap: str | None = None
|
|
44
|
+
description: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ObsField = Annotated[CategoricalField | ContinuousField, Field(discriminator="type")]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ColorSpec(_Model):
|
|
51
|
+
kind: Literal["gene", "field"]
|
|
52
|
+
gene: str | None = None
|
|
53
|
+
field: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Layout(_Model):
|
|
57
|
+
mode: LayoutMode = "grid"
|
|
58
|
+
gutterFraction: float = 0.1
|
|
59
|
+
order: list[str] = Field(default_factory=list)
|
|
60
|
+
columns: int | None = None # grid: a fixed number of columns
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ImageChannel(_Model):
|
|
64
|
+
name: str
|
|
65
|
+
color: str
|
|
66
|
+
window: tuple[float, float]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SampleImage(_Model):
|
|
70
|
+
id: str
|
|
71
|
+
name: str
|
|
72
|
+
path: str
|
|
73
|
+
kind: Literal["rgb", "multichannel"] = "rgb"
|
|
74
|
+
pixelSize: float
|
|
75
|
+
translate: tuple[float, float] = (0.0, 0.0)
|
|
76
|
+
size: tuple[int, int] # level-0 (height, width) in pixels
|
|
77
|
+
channels: list[ImageChannel] | None = None
|
|
78
|
+
defaultOpacity: float = 1.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ExprSpec(_Model):
|
|
82
|
+
kind: Literal["u8", "csc"] = "u8"
|
|
83
|
+
sharded: bool = True
|
|
84
|
+
shardGenes: int = 512
|
|
85
|
+
normalized: Literal["lognorm", "log1p_cp10k"] | None = None # as stored by the source, or log1p(CP10k) applied by the build
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PolygonInfo(_Model):
|
|
89
|
+
path: str = "polygons" # polygons.i16 + polygons.offsets.u32
|
|
90
|
+
cells: int = 0 # cells that have a polygon
|
|
91
|
+
vertices: int = 0
|
|
92
|
+
scale: float = 0.1 # µm per int16 unit (deltas from the cell centroid)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TrackSet(_Model):
|
|
96
|
+
field: str
|
|
97
|
+
categories: dict[str, str] # category label -> bigWig path relative to the sample folder
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TracksInfo(_Model):
|
|
101
|
+
"""Genome tracks of an ATAC sample (see tracks.py): bigWigs of insertions per million per category."""
|
|
102
|
+
|
|
103
|
+
genome: str
|
|
104
|
+
bin: int
|
|
105
|
+
all: str | None = None # bigWig of every cell: built from fragments, or given with bigwigs
|
|
106
|
+
units: str = "insertions per million" # what the tracks measure, for the panel's scale
|
|
107
|
+
by: list[TrackSet] = Field(default_factory=list)
|
|
108
|
+
peaks: str | None = None # BED of the sample's peak features
|
|
109
|
+
genes: str | None = None # folder of gene models: index.json (names per chromosome) + <chrom>.json (see genes.py)
|
|
110
|
+
links: str | None = None # BEDPE of peak-gene links
|
|
111
|
+
chromSizes: str = "tracks/chrom.sizes"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Sample(_Model):
|
|
115
|
+
id: str
|
|
116
|
+
name: str
|
|
117
|
+
platform: Platform
|
|
118
|
+
kind: SampleKind = "spatial"
|
|
119
|
+
group: str | None = None
|
|
120
|
+
nObs: int
|
|
121
|
+
nGenes: int
|
|
122
|
+
bbox: tuple[float, float, float, float]
|
|
123
|
+
toDataset: list[float] = Field(default_factory=lambda: [1, 0, 0, 0, 1, 0, 0, 0, 1])
|
|
124
|
+
pointRadius: float
|
|
125
|
+
expr: ExprSpec = Field(default_factory=ExprSpec)
|
|
126
|
+
hasF16: bool = False
|
|
127
|
+
fields: list[str] = Field(default_factory=list)
|
|
128
|
+
images: list[SampleImage] = Field(default_factory=list)
|
|
129
|
+
outlines: list[str] = Field(default_factory=list) # categorical field ids with outlines/<field>.json
|
|
130
|
+
polygons: PolygonInfo | None = None
|
|
131
|
+
idBlock: int = 65536
|
|
132
|
+
suggestedGene: str | None = None # most variable gene in this sample; the dataset's default when none is configured
|
|
133
|
+
tracks: TracksInfo | None = None # genome tracks (ATAC samples)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FeatureGroup(_Model):
|
|
137
|
+
id: str
|
|
138
|
+
name: str
|
|
139
|
+
units: str | None = None
|
|
140
|
+
count: int = 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Manifest(_Model):
|
|
144
|
+
formatVersion: Literal[1] = FORMAT_VERSION
|
|
145
|
+
id: str
|
|
146
|
+
name: str
|
|
147
|
+
description: str | None = None
|
|
148
|
+
defaultGene: str | None = None
|
|
149
|
+
defaultColor: ColorSpec
|
|
150
|
+
layout: Layout = Field(default_factory=Layout)
|
|
151
|
+
colormaps: list[str] = Field(default_factory=lambda: list(BUILTIN_COLORMAPS))
|
|
152
|
+
vocabularies: dict[str, Vocabulary] = Field(default_factory=dict)
|
|
153
|
+
fields: list[ObsField] = Field(default_factory=list)
|
|
154
|
+
featureGroups: list[FeatureGroup] = Field(default_factory=list)
|
|
155
|
+
samples: list[Sample] = Field(default_factory=list)
|
|
156
|
+
thumbnailSample: str | None = None # sample drawn on the dataset thumbnail (default: first spatial sample in layout order)
|
|
157
|
+
|
|
158
|
+
def field_by_id(self, fid: str) -> CategoricalField | ContinuousField | None:
|
|
159
|
+
for f in self.fields:
|
|
160
|
+
if f.id == fid:
|
|
161
|
+
return f
|
|
162
|
+
return None
|
spatialscape/obs.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Per-observation arrays: positions, shuffle order, ids, categorical codes, continuous values."""
|
|
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 zarr.codecs import ZstdCodec
|
|
10
|
+
|
|
11
|
+
MAX_CHUNK = 1 << 20
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _chunk(n: int) -> int:
|
|
15
|
+
return max(1, min(n, MAX_CHUNK))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_obs(
|
|
19
|
+
out_dir: Path,
|
|
20
|
+
*,
|
|
21
|
+
xy: np.ndarray,
|
|
22
|
+
order: np.ndarray,
|
|
23
|
+
ids: list[str],
|
|
24
|
+
cats: dict[str, np.ndarray],
|
|
25
|
+
nums: dict[str, np.ndarray],
|
|
26
|
+
id_block: int = 65536,
|
|
27
|
+
) -> None:
|
|
28
|
+
n = xy.shape[0]
|
|
29
|
+
store = zarr.storage.LocalStore(str(out_dir / "obs.zarr"))
|
|
30
|
+
root = zarr.open_group(store, mode="w", zarr_format=3)
|
|
31
|
+
z = ZstdCodec(level=5)
|
|
32
|
+
a = root.create_array("xy", shape=(n, 2), chunks=(_chunk(n), 2), dtype="float32", compressors=[z])
|
|
33
|
+
a[:] = xy.astype(np.float32)
|
|
34
|
+
o = root.create_array("order", shape=(n,), chunks=(_chunk(n),), dtype="uint32", compressors=[z])
|
|
35
|
+
o[:] = order.astype(np.uint32)
|
|
36
|
+
cg = root.create_group("cat")
|
|
37
|
+
for fid, codes in cats.items():
|
|
38
|
+
arr = cg.create_array(fid, shape=(n,), chunks=(_chunk(n),), dtype="uint16", compressors=[z])
|
|
39
|
+
arr[:] = codes.astype(np.uint16)
|
|
40
|
+
ng = root.create_group("num")
|
|
41
|
+
for fid, vals in nums.items():
|
|
42
|
+
arr = ng.create_array(fid, shape=(n,), chunks=(_chunk(n),), dtype="float32", compressors=[z])
|
|
43
|
+
arr[:] = vals.astype(np.float32)
|
|
44
|
+
ids_dir = out_dir / "ids"
|
|
45
|
+
ids_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
for b, start in enumerate(range(0, n, id_block)):
|
|
47
|
+
with open(ids_dir / f"{b}.json", "w") as fh:
|
|
48
|
+
json.dump(ids[start : start + id_block], fh, separators=(",", ":"))
|
spatialscape/outlines.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Boundary polylines for categorical fields (e.g. spatial domains), computed from a bundle's obs arrays.
|
|
2
|
+
|
|
3
|
+
Approach: rasterize labels onto a grid (nearest point within ~0.75 x spacing), majority-smooth,
|
|
4
|
+
trace each category with marching squares, simplify (RDP), and store as µm polylines.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy import ndimage
|
|
13
|
+
from scipy.spatial import cKDTree
|
|
14
|
+
|
|
15
|
+
MAX_GRID_CELLS = 2_000_000
|
|
16
|
+
BACKGROUND = -1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _median_nn(xy: np.ndarray, sample: int = 3000, seed: int = 0) -> float:
|
|
20
|
+
rng = np.random.default_rng(seed)
|
|
21
|
+
idx = rng.choice(len(xy), size=min(sample, len(xy)), replace=False)
|
|
22
|
+
d, _ = cKDTree(xy).query(xy[idx], k=2)
|
|
23
|
+
return float(np.median(d[:, 1]))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rdp(points: np.ndarray, tol: float) -> np.ndarray:
|
|
27
|
+
"""Iterative Ramer-Douglas-Peucker for an (n, 2) polyline."""
|
|
28
|
+
n = len(points)
|
|
29
|
+
if n < 3:
|
|
30
|
+
return points
|
|
31
|
+
keep = np.zeros(n, dtype=bool)
|
|
32
|
+
keep[0] = keep[-1] = True
|
|
33
|
+
stack = [(0, n - 1)]
|
|
34
|
+
while stack:
|
|
35
|
+
a, b = stack.pop()
|
|
36
|
+
if b <= a + 1:
|
|
37
|
+
continue
|
|
38
|
+
seg = points[b] - points[a]
|
|
39
|
+
L = np.hypot(*seg)
|
|
40
|
+
pts = points[a + 1 : b]
|
|
41
|
+
if L == 0:
|
|
42
|
+
d = np.hypot(*(pts - points[a]).T)
|
|
43
|
+
else:
|
|
44
|
+
d = np.abs(seg[0] * (points[a][1] - pts[:, 1]) - (points[a][0] - pts[:, 0]) * seg[1]) / L
|
|
45
|
+
i = int(np.argmax(d))
|
|
46
|
+
if d[i] > tol:
|
|
47
|
+
k = a + 1 + i
|
|
48
|
+
keep[k] = True
|
|
49
|
+
stack.append((a, k))
|
|
50
|
+
stack.append((k, b))
|
|
51
|
+
return points[keep]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_outlines(
|
|
55
|
+
xy: np.ndarray,
|
|
56
|
+
codes: np.ndarray,
|
|
57
|
+
n_categories: int,
|
|
58
|
+
*,
|
|
59
|
+
skip_codes: set[int] | None = None,
|
|
60
|
+
bin_um: float | None = None,
|
|
61
|
+
min_cells: int = 25,
|
|
62
|
+
smooth_um: float = 150.0,
|
|
63
|
+
min_feature_um: float = 300.0,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Return {"bin": bin_um, "paths": [{"c": code, "p": [[x, y], ...]}, ...]} in the xy units (µm)."""
|
|
66
|
+
from skimage.measure import find_contours
|
|
67
|
+
|
|
68
|
+
skip_codes = skip_codes or set()
|
|
69
|
+
xy = np.asarray(xy, dtype=np.float64)
|
|
70
|
+
codes = np.asarray(codes).astype(np.int64)
|
|
71
|
+
nn = _median_nn(xy)
|
|
72
|
+
# unlabeled cells (e.g. "NA") are treated as unknown: dropped so neighbours fill their space
|
|
73
|
+
if skip_codes:
|
|
74
|
+
keep = ~np.isin(codes, list(skip_codes))
|
|
75
|
+
if keep.sum() >= min_cells:
|
|
76
|
+
xy, codes = xy[keep], codes[keep]
|
|
77
|
+
b = float(bin_um) if bin_um else max(0.5, 0.5 * nn)
|
|
78
|
+
x0, y0 = xy.min(axis=0)
|
|
79
|
+
x1, y1 = xy.max(axis=0)
|
|
80
|
+
while ((x1 - x0) / b + 3) * ((y1 - y0) / b + 3) > MAX_GRID_CELLS:
|
|
81
|
+
b *= 1.5
|
|
82
|
+
w = int(np.ceil((x1 - x0) / b)) + 3
|
|
83
|
+
h = int(np.ceil((y1 - y0) / b)) + 3
|
|
84
|
+
gx = x0 - b + (np.arange(w) + 0.5) * b
|
|
85
|
+
gy = y0 - b + (np.arange(h) + 0.5) * b
|
|
86
|
+
GX, GY = np.meshgrid(gx, gy)
|
|
87
|
+
tree = cKDTree(xy)
|
|
88
|
+
# fill radius: 2x the median spacing closes gaps inside tissue but keeps real holes/edges as background
|
|
89
|
+
r = max(2.0 * nn, 0.75 * b * np.sqrt(2))
|
|
90
|
+
d, i = tree.query(np.column_stack([GX.ravel(), GY.ravel()]), k=1, distance_upper_bound=r)
|
|
91
|
+
lab = np.full(GX.size, BACKGROUND, dtype=np.int64)
|
|
92
|
+
ok = np.isfinite(d)
|
|
93
|
+
lab[ok] = codes[i[ok]]
|
|
94
|
+
lab = lab.reshape(h, w)
|
|
95
|
+
|
|
96
|
+
# majority smoothing over a (smooth_um)^2 window (one-hot sums, argmax); background competes too
|
|
97
|
+
counts = np.bincount(codes, minlength=n_categories)
|
|
98
|
+
cats = [c for c in range(n_categories) if counts[c] >= min_cells and c not in skip_codes]
|
|
99
|
+
smooth = int(max(3, round(smooth_um / b)) | 1) # odd window size in bins
|
|
100
|
+
if smooth > 1:
|
|
101
|
+
best = np.zeros((h, w), dtype=np.float32)
|
|
102
|
+
best_lab = np.full((h, w), BACKGROUND, dtype=np.int64)
|
|
103
|
+
for c in [BACKGROUND, *cats]:
|
|
104
|
+
s = ndimage.uniform_filter((lab == c).astype(np.float32), size=smooth, mode="constant")
|
|
105
|
+
upd = s > best
|
|
106
|
+
best[upd] = s[upd]
|
|
107
|
+
best_lab[upd] = c
|
|
108
|
+
lab = best_lab
|
|
109
|
+
|
|
110
|
+
import warnings
|
|
111
|
+
|
|
112
|
+
from skimage.morphology import remove_small_holes, remove_small_objects
|
|
113
|
+
|
|
114
|
+
# drop islands/holes smaller than (min_feature_um)^2 so outlines follow regions, not single cells
|
|
115
|
+
min_px = int(max(16, (min_feature_um / b) ** 2))
|
|
116
|
+
paths = []
|
|
117
|
+
tol = 0.35 * b
|
|
118
|
+
for c in cats:
|
|
119
|
+
m = lab == c
|
|
120
|
+
with warnings.catch_warnings(): # skimage 0.26 renamed these parameters; both spellings still work
|
|
121
|
+
warnings.simplefilter("ignore", FutureWarning)
|
|
122
|
+
m = remove_small_objects(m, min_size=min_px)
|
|
123
|
+
m = remove_small_holes(m, area_threshold=min_px)
|
|
124
|
+
mask = np.pad(m.astype(np.float32), 1)
|
|
125
|
+
for cont in find_contours(mask, 0.5):
|
|
126
|
+
if len(cont) < 6:
|
|
127
|
+
continue
|
|
128
|
+
pts = np.column_stack([x0 - b + (cont[:, 1] - 1 + 0.5) * b, y0 - b + (cont[:, 0] - 1 + 0.5) * b])
|
|
129
|
+
pts = _rdp(pts, tol)
|
|
130
|
+
if len(pts) < 4:
|
|
131
|
+
continue
|
|
132
|
+
per = np.hypot(*np.diff(pts, axis=0).T).sum()
|
|
133
|
+
if per < 10 * b:
|
|
134
|
+
continue
|
|
135
|
+
paths.append({"c": int(c), "p": np.round(pts, 1).tolist()})
|
|
136
|
+
return {"bin": round(b, 3), "paths": paths}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_outlines(out_dir: Path, field: str, data: dict) -> Path:
|
|
140
|
+
d = Path(out_dir) / "outlines"
|
|
141
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
p = d / f"{field}.json"
|
|
143
|
+
with open(p, "w") as fh:
|
|
144
|
+
json.dump(data, fh, separators=(",", ":"))
|
|
145
|
+
return p
|
spatialscape/polygons.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Per-cell boundary polygons packed as int16 deltas from the cell centroid.
|
|
2
|
+
|
|
3
|
+
Files (sample-local µm frame, bundle row order):
|
|
4
|
+
polygons.offsets.u32 uint32[n_obs + 1] vertex start index per cell (zero-length = no polygon)
|
|
5
|
+
polygons.i16 int16[n_vertices * 2] (dx, dy) * 10 relative to the cell's xy
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pyarrow.parquet as pq
|
|
14
|
+
|
|
15
|
+
SCALE = 0.1 # µm per unit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_polygon_table(path: Path, id_col: str, x_col: str, y_col: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
19
|
+
t = pq.read_table(path, columns=[id_col, x_col, y_col])
|
|
20
|
+
ids = np.asarray(t.column(id_col).to_pylist(), dtype=str)
|
|
21
|
+
x = t.column(x_col).to_numpy(zero_copy_only=False).astype(np.float64)
|
|
22
|
+
y = t.column(y_col).to_numpy(zero_copy_only=False).astype(np.float64)
|
|
23
|
+
return ids, x, y
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_polygon_obsm(adata, key: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
27
|
+
"""Flatten an obsm array of shape (n_obs, n_vertices, 2) into (ids, x, y); NaN-padded vertices are dropped."""
|
|
28
|
+
arr = np.asarray(adata.obsm[key], dtype=np.float64)
|
|
29
|
+
if arr.ndim != 3 or arr.shape[2] != 2:
|
|
30
|
+
raise ValueError(f"obsm/{key}: expected shape (n_obs, n_vertices, 2), got {arr.shape}")
|
|
31
|
+
_n, v, _ = arr.shape
|
|
32
|
+
ids = np.repeat(np.asarray(adata.obs_names, dtype=str), v)
|
|
33
|
+
x, y = arr[:, :, 0].ravel(), arr[:, :, 1].ravel()
|
|
34
|
+
ok = np.isfinite(x) & np.isfinite(y)
|
|
35
|
+
return ids[ok], x[ok], y[ok]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def apply_affine(x: np.ndarray, y: np.ndarray, affine) -> tuple[np.ndarray, np.ndarray]:
|
|
39
|
+
a, b, c, d, e, f = (float(v) for v in affine)
|
|
40
|
+
return a * x + b * y + c, d * x + e * y + f
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pack_polygons(
|
|
44
|
+
obs_ids: list[str],
|
|
45
|
+
xy_um: np.ndarray,
|
|
46
|
+
poly_ids: np.ndarray,
|
|
47
|
+
poly_xy_um: np.ndarray,
|
|
48
|
+
*,
|
|
49
|
+
max_vertices: int = 24,
|
|
50
|
+
) -> tuple[np.ndarray, np.ndarray, int, float]:
|
|
51
|
+
"""Return (offsets uint32[n+1], deltas int16[nv*2], n_cells_with_polygon, median_centroid_offset_um) in bundle
|
|
52
|
+
row order. The last value is the median distance between a polygon's mean vertex and its cell's xy; a large
|
|
53
|
+
value means the polygons are in a different frame than the coordinates."""
|
|
54
|
+
order = np.argsort(poly_ids, kind="stable")
|
|
55
|
+
poly_ids = poly_ids[order]
|
|
56
|
+
poly_xy_um = poly_xy_um[order]
|
|
57
|
+
uniq, start, counts = np.unique(poly_ids, return_index=True, return_counts=True)
|
|
58
|
+
lookup = {u: (int(s), int(c)) for u, s, c in zip(uniq, start, counts, strict=True)}
|
|
59
|
+
n = len(obs_ids)
|
|
60
|
+
offsets = np.zeros(n + 1, dtype=np.uint32)
|
|
61
|
+
chunks: list[np.ndarray] = []
|
|
62
|
+
total = 0
|
|
63
|
+
with_poly = 0
|
|
64
|
+
dev: list[float] = []
|
|
65
|
+
for i, cid in enumerate(obs_ids):
|
|
66
|
+
hit = lookup.get(str(cid))
|
|
67
|
+
if hit:
|
|
68
|
+
s, c = hit
|
|
69
|
+
pts = poly_xy_um[s : s + c]
|
|
70
|
+
dev.append(float(np.hypot(*(pts.mean(axis=0) - xy_um[i]))))
|
|
71
|
+
if c > max_vertices:
|
|
72
|
+
pts = pts[np.linspace(0, c - 1, max_vertices).round().astype(int)]
|
|
73
|
+
d = np.rint((pts - xy_um[i]) / SCALE)
|
|
74
|
+
d = np.clip(d, -32767, 32767).astype(np.int16)
|
|
75
|
+
chunks.append(d.ravel())
|
|
76
|
+
total += len(pts)
|
|
77
|
+
with_poly += 1
|
|
78
|
+
offsets[i + 1] = total
|
|
79
|
+
deltas = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.int16)
|
|
80
|
+
return offsets, deltas, with_poly, (float(np.median(dev)) if dev else 0.0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_polygons(out_dir: Path, offsets: np.ndarray, deltas: np.ndarray) -> None:
|
|
84
|
+
(Path(out_dir) / "polygons.offsets.u32").write_bytes(offsets.astype("<u4").tobytes())
|
|
85
|
+
(Path(out_dir) / "polygons.i16").write_bytes(deltas.astype("<i2").tobytes())
|