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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Expression matrix: normalization detection, uint8 quantization, gene-major zarr v3 writer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import anndata as ad
|
|
8
|
+
import numpy as np
|
|
9
|
+
import scipy.sparse as sp
|
|
10
|
+
import zarr
|
|
11
|
+
from zarr.codecs import ZstdCodec
|
|
12
|
+
|
|
13
|
+
TARGET_SHARD_BYTES = 128 * 1024 * 1024
|
|
14
|
+
ENTRY_BLOCK = 4 * 1024 * 1024 # stored values quantized per step: bounds the per-entry temporaries, not the shard
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ExprResult:
|
|
19
|
+
gmax: np.ndarray # float32 (n_genes_kept,)
|
|
20
|
+
keep: np.ndarray # bool (n_vars,) genes retained (non-zero)
|
|
21
|
+
shard_genes: int
|
|
22
|
+
sharded: bool
|
|
23
|
+
gmin: np.ndarray | None = None # float32 (n_genes_kept,): 0 for non-negative features, -gmax for signed ones
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_matrix(adata: ad.AnnData, layer: str) -> sp.csr_matrix | sp.csc_matrix:
|
|
27
|
+
"""The (n_obs, n_vars) matrix as float32, CSR or CSC as stored: h5ad files hold CSC, and the writer wants CSC,
|
|
28
|
+
so converting to CSR here only to convert back cost a full pass over the data twice."""
|
|
29
|
+
M = adata.X if layer == "X" else adata.layers[layer]
|
|
30
|
+
if sp.issparse(M):
|
|
31
|
+
M = M.tocsc() if M.format not in ("csr", "csc") else M
|
|
32
|
+
else:
|
|
33
|
+
M = sp.csc_matrix(np.asarray(M))
|
|
34
|
+
return M.astype(np.float32, copy=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_lognorm(M: sp.csr_matrix, sample: int = 1_000_000) -> bool:
|
|
38
|
+
data = M.data
|
|
39
|
+
if data.size == 0:
|
|
40
|
+
return True
|
|
41
|
+
if data.size > sample:
|
|
42
|
+
rng = np.random.default_rng(0)
|
|
43
|
+
data = data[rng.choice(data.size, sample, replace=False)]
|
|
44
|
+
# log-normalized data is essentially never all-integer; raw counts always are (also at low depth, where the
|
|
45
|
+
# old "max <= 20" rule mistook Visium HD bins and small-panel runs for normalized data)
|
|
46
|
+
return not np.allclose(data, np.rint(data))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def log1p_cp10k(M):
|
|
50
|
+
"""log1p(CP10k) of a CSR/CSC counts matrix: each stored value times its row's factor, in a new matrix that shares
|
|
51
|
+
the index arrays with the input. `diags(scale) @ M` was a full sparse product whose result came back with
|
|
52
|
+
unsorted indices, so every later reduction sorted the whole matrix again."""
|
|
53
|
+
if not sp.issparse(M) or M.format not in ("csr", "csc"):
|
|
54
|
+
M = sp.csc_matrix(M)
|
|
55
|
+
tot = np.asarray(M.sum(axis=1)).ravel()
|
|
56
|
+
scale = np.where(tot > 0, 1e4 / np.maximum(tot, 1e-12), 0.0).astype(np.float32)
|
|
57
|
+
rows = M.indices if M.format == "csc" else np.repeat(np.arange(M.shape[0]), np.diff(M.indptr))
|
|
58
|
+
data = M.data.astype(np.float32) # a copy: the caller's matrix is never modified
|
|
59
|
+
data *= scale[rows]
|
|
60
|
+
np.log1p(data, out=data)
|
|
61
|
+
out = M.__class__((data, M.indices, M.indptr), shape=M.shape)
|
|
62
|
+
if M.has_canonical_format:
|
|
63
|
+
out.has_canonical_format = True
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def choose_shard_genes(n_obs: int, requested: int) -> int:
|
|
68
|
+
return int(max(1, min(requested, TARGET_SHARD_BYTES // max(n_obs, 1))))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ZSTD_LEVEL = 7
|
|
72
|
+
EMPTY = np.iinfo(np.uint64).max # a shard index entry for a chunk that was never written
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ShardWriter:
|
|
76
|
+
"""Writes the gene-major uint8 array's chunks itself, in the exact zarr v3 layout zarr-python declared in
|
|
77
|
+
zarr.json: per shard `c/<i>/0` = the zstd frames of its rows back to back, then the index (offset, nbytes as
|
|
78
|
+
little-endian uint64 per inner chunk, EMPTY for chunks past the end) followed by its crc32c; unsharded, one
|
|
79
|
+
`c/<g>/0` per gene. Going around zarr-python's per-chunk pipeline removes the few hundred thousand asyncio
|
|
80
|
+
tasks that made up ~90% of a build, and lets the zstd calls run in threads (they release the GIL)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, array_dir: Path, n_obs: int, shard_genes: int, *, sharded: bool, level: int = ZSTD_LEVEL, workers: int | None = None):
|
|
83
|
+
import os
|
|
84
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
85
|
+
|
|
86
|
+
from numcodecs import Zstd
|
|
87
|
+
|
|
88
|
+
self.dir = Path(array_dir) / "c"
|
|
89
|
+
self.n_obs = n_obs
|
|
90
|
+
self.S = shard_genes
|
|
91
|
+
self.sharded = sharded
|
|
92
|
+
self.codec = Zstd(level=level)
|
|
93
|
+
self.pool = ThreadPoolExecutor(max_workers=workers or min(8, os.cpu_count() or 1))
|
|
94
|
+
|
|
95
|
+
def _encode_rows(self, q: np.ndarray) -> list[bytes]:
|
|
96
|
+
rows = [np.ascontiguousarray(q[i]) for i in range(q.shape[0])]
|
|
97
|
+
return list(self.pool.map(lambda r: bytes(self.codec.encode(r)), rows))
|
|
98
|
+
|
|
99
|
+
def write(self, g0: int, q: np.ndarray) -> None:
|
|
100
|
+
"""Rows g0..g0+len(q) of the array; with sharding, g0 must be a multiple of the shard size."""
|
|
101
|
+
import google_crc32c
|
|
102
|
+
|
|
103
|
+
frames = self._encode_rows(q)
|
|
104
|
+
if not self.sharded:
|
|
105
|
+
for k, frame in enumerate(frames):
|
|
106
|
+
d = self.dir / str(g0 + k)
|
|
107
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
(d / "0").write_bytes(frame)
|
|
109
|
+
return
|
|
110
|
+
if g0 % self.S:
|
|
111
|
+
raise ValueError("shard writes must start on a shard boundary")
|
|
112
|
+
index = np.full((self.S, 2), EMPTY, dtype="<u8")
|
|
113
|
+
offset = 0
|
|
114
|
+
for k, frame in enumerate(frames):
|
|
115
|
+
index[k] = (offset, len(frame))
|
|
116
|
+
offset += len(frame)
|
|
117
|
+
idx_bytes = index.tobytes()
|
|
118
|
+
d = self.dir / str(g0 // self.S)
|
|
119
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
with open(d / "0", "wb") as fh:
|
|
121
|
+
fh.writelines(frames)
|
|
122
|
+
fh.write(idx_bytes)
|
|
123
|
+
fh.write(np.uint32(google_crc32c.value(idx_bytes)).tobytes())
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
self.pool.shutdown(wait=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def column_ranges(Mc: sp.csc_matrix) -> tuple[np.ndarray, np.ndarray]:
|
|
130
|
+
"""Per-column (min, max) of a CSC matrix including its implicit zeros, straight from indptr/data. scipy's
|
|
131
|
+
`.max(axis=0)` first sorts and deduplicates the indices of a matrix read from disk, a 4 s pass on a 15k x 37k
|
|
132
|
+
object; this reads the data once and needs no particular index order."""
|
|
133
|
+
n_obs, n_vars = Mc.shape
|
|
134
|
+
nnz_col = np.diff(Mc.indptr)
|
|
135
|
+
gmax = np.zeros(n_vars, dtype=np.float32)
|
|
136
|
+
gmin = np.zeros(n_vars, dtype=np.float32)
|
|
137
|
+
has = nnz_col > 0
|
|
138
|
+
if has.any():
|
|
139
|
+
starts = Mc.indptr[:-1][has]
|
|
140
|
+
data = Mc.data
|
|
141
|
+
gmax[has] = np.maximum.reduceat(data, starts)
|
|
142
|
+
gmin[has] = np.minimum.reduceat(data, starts)
|
|
143
|
+
sparse_cols = nnz_col < n_obs # an implicit zero takes part in the range
|
|
144
|
+
gmax = np.where(sparse_cols, np.maximum(gmax, 0), gmax).astype(np.float32)
|
|
145
|
+
gmin = np.where(sparse_cols, np.minimum(gmin, 0), gmin).astype(np.float32)
|
|
146
|
+
return gmin, gmax
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def column_moments(Mc: sp.csc_matrix) -> tuple[np.ndarray, np.ndarray]:
|
|
150
|
+
"""Per-column (mean, variance) of a CSC matrix over all rows, implicit zeros included, in one pass over the data
|
|
151
|
+
and in any index order (scipy's `.mean()` sorts and deduplicates the matrix first)."""
|
|
152
|
+
n_obs, n_vars = Mc.shape
|
|
153
|
+
nnz_col = np.diff(Mc.indptr)
|
|
154
|
+
s1 = np.zeros(n_vars)
|
|
155
|
+
s2 = np.zeros(n_vars)
|
|
156
|
+
has = nnz_col > 0
|
|
157
|
+
if has.any():
|
|
158
|
+
starts = Mc.indptr[:-1][has]
|
|
159
|
+
d = Mc.data.astype(np.float64, copy=False)
|
|
160
|
+
s1[has] = np.add.reduceat(d, starts)
|
|
161
|
+
s2[has] = np.add.reduceat(d * d, starts)
|
|
162
|
+
n = max(n_obs, 1)
|
|
163
|
+
mean = s1 / n
|
|
164
|
+
return mean, np.maximum(s2 / n - mean * mean, 0.0)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def feature_ranges(gmin: np.ndarray, gmax: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
168
|
+
"""The [lo, hi] each feature is quantized over: [0, max] for non-negative features; for features with negative
|
|
169
|
+
values (motif deviations, module scores) a symmetric [-m, m] with m = max |x|, so zero sits at the centre."""
|
|
170
|
+
signed = gmin < 0
|
|
171
|
+
m = np.maximum(np.abs(gmin), np.abs(gmax))
|
|
172
|
+
lo = np.where(signed, -m, 0.0).astype(np.float32)
|
|
173
|
+
hi = np.where(signed, m, gmax).astype(np.float32)
|
|
174
|
+
return lo, hi
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def quantize_values(x: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> np.ndarray:
|
|
178
|
+
"""uint8 codes of x over [lo, hi] (lo/hi broadcast against x): code = rint((x - lo) * 255 / (hi - lo)), and a
|
|
179
|
+
positive value of a non-negative feature never rounds to 0, so an expressed cell never renders as zero."""
|
|
180
|
+
lo = np.asarray(lo, dtype=np.float32)
|
|
181
|
+
span = (np.asarray(hi, dtype=np.float32) - lo).astype(np.float32)
|
|
182
|
+
scale = np.where(span > 0, 255.0 / np.maximum(span, 1e-12), 0.0).astype(np.float32)
|
|
183
|
+
q = np.subtract(x, lo, dtype=np.float32) # one working buffer, updated in place
|
|
184
|
+
np.multiply(q, scale, out=q)
|
|
185
|
+
np.rint(q, out=q)
|
|
186
|
+
np.clip(q, 0, 255, out=q)
|
|
187
|
+
bump = (lo >= 0) & (x > 0) & (q < 1)
|
|
188
|
+
if bump.any():
|
|
189
|
+
q[bump] = 1
|
|
190
|
+
return q.astype(np.uint8)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def quantize_block(block: np.ndarray, gmax: np.ndarray, gmin: np.ndarray | None = None) -> np.ndarray:
|
|
194
|
+
"""block (g, n) float32 -> uint8 over [gmin, gmax] per row (gmin defaults to 0). value = gmin + u8 / 255 * (gmax - gmin)."""
|
|
195
|
+
lo = np.zeros_like(gmax, dtype=np.float32) if gmin is None else np.asarray(gmin, dtype=np.float32)
|
|
196
|
+
return quantize_values(block, lo[:, None], np.asarray(gmax, dtype=np.float32)[:, None])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_expression(
|
|
200
|
+
M: sp.csr_matrix,
|
|
201
|
+
out_dir: Path,
|
|
202
|
+
*,
|
|
203
|
+
shard_genes: int = 512,
|
|
204
|
+
sharded: bool = True,
|
|
205
|
+
keep_f16: bool = False,
|
|
206
|
+
order: np.ndarray | None = None,
|
|
207
|
+
log=lambda *_: None,
|
|
208
|
+
) -> ExprResult:
|
|
209
|
+
"""M is (n_obs, n_vars) normalized, CSR or CSC. Writes out_dir/expr.zarr/{u8,gmax[,f16]}. With `order` (a
|
|
210
|
+
permutation), row i of the written array is observation order[i]: the LOD shuffle is applied while scattering
|
|
211
|
+
the stored values, so the matrix itself is never permuted (a copy with unsorted indices that every later
|
|
212
|
+
reduction would sort again)."""
|
|
213
|
+
n_obs, n_vars = M.shape
|
|
214
|
+
Mc = M.tocsc()
|
|
215
|
+
if Mc.nnz and not Mc.has_canonical_format:
|
|
216
|
+
Mc = Mc.copy() # duplicate entries must sum, as in a dense view; canonicalise a copy, never the caller's matrix
|
|
217
|
+
Mc.sum_duplicates()
|
|
218
|
+
inv = None
|
|
219
|
+
if order is not None:
|
|
220
|
+
order = np.asarray(order)
|
|
221
|
+
if order.shape != (n_obs,) or not np.array_equal(np.sort(order), np.arange(n_obs)):
|
|
222
|
+
raise ValueError("order must be a permutation of the observations")
|
|
223
|
+
inv = np.empty(n_obs, dtype=np.int64)
|
|
224
|
+
inv[order] = np.arange(n_obs)
|
|
225
|
+
gmax_all = np.zeros(n_vars, dtype=np.float32)
|
|
226
|
+
gmin_all = np.zeros(n_vars, dtype=np.float32)
|
|
227
|
+
if Mc.nnz:
|
|
228
|
+
if not np.all(np.isfinite(Mc.data)):
|
|
229
|
+
bad = np.unique(Mc.indices[~np.isfinite(Mc.data)]) if hasattr(Mc, "indices") else []
|
|
230
|
+
cols = np.repeat(np.arange(n_vars), np.diff(Mc.indptr))[~np.isfinite(Mc.data)] if hasattr(Mc, "indptr") else bad
|
|
231
|
+
names = ", ".join(str(c) for c in np.unique(cols)[:5])
|
|
232
|
+
raise ValueError(f"expression has NaN or infinite values (feature columns {names}{'...' if len(np.unique(cols)) > 5 else ''}); fix or drop those features before building")
|
|
233
|
+
gmin_all, gmax_all = column_ranges(Mc)
|
|
234
|
+
lo_all, hi_all = feature_ranges(gmin_all, gmax_all)
|
|
235
|
+
keep = hi_all > 0 # all-zero features are dropped
|
|
236
|
+
gmax = hi_all[keep]
|
|
237
|
+
gmin = lo_all[keep]
|
|
238
|
+
G = int(keep.sum())
|
|
239
|
+
|
|
240
|
+
S = choose_shard_genes(n_obs, shard_genes) if sharded else max(1, min(shard_genes, G))
|
|
241
|
+
store = zarr.storage.LocalStore(str(out_dir / "expr.zarr"))
|
|
242
|
+
root = zarr.open_group(store, mode="w", zarr_format=3)
|
|
243
|
+
kwargs: dict = {
|
|
244
|
+
"shape": (G, n_obs),
|
|
245
|
+
"chunks": (1, n_obs),
|
|
246
|
+
"compressors": [ZstdCodec(level=ZSTD_LEVEL)],
|
|
247
|
+
"fill_value": 0,
|
|
248
|
+
"dimension_names": ("gene", "obs"),
|
|
249
|
+
}
|
|
250
|
+
if sharded:
|
|
251
|
+
kwargs["shards"] = (S, n_obs)
|
|
252
|
+
root.create_array("u8", dtype="uint8", **kwargs) # zarr writes the metadata; the chunks below are ours
|
|
253
|
+
f16 = root.create_array("f16", dtype="float16", **kwargs) if keep_f16 else None
|
|
254
|
+
writer = ShardWriter(out_dir / "expr.zarr" / "u8", n_obs, S if sharded else 1, sharded=sharded)
|
|
255
|
+
|
|
256
|
+
# Only the stored values are quantized; implicit zeros are the shard's fill (0 unless the feature is signed).
|
|
257
|
+
# That is one pass over the nnz values instead of six over a dense n_genes x n_obs float32 block, and the
|
|
258
|
+
# kept columns are gathered per block straight out of the input, so the matrix is never copied or sliced.
|
|
259
|
+
kept = np.flatnonzero(keep) # input column of each kept gene
|
|
260
|
+
nnz_col = np.diff(Mc.indptr)
|
|
261
|
+
kcum = np.zeros(G + 1, dtype=np.int64) # stored values before each kept gene
|
|
262
|
+
np.cumsum(nnz_col[kept], out=kcum[1:])
|
|
263
|
+
zero_code = quantize_values(np.zeros(G, dtype=np.float32), gmin, gmax)
|
|
264
|
+
for g0 in range(0, G, S):
|
|
265
|
+
g1 = min(G, g0 + S)
|
|
266
|
+
q = np.empty((g1 - g0, n_obs), dtype=np.uint8)
|
|
267
|
+
q[:] = zero_code[g0:g1, None]
|
|
268
|
+
h = np.zeros((g1 - g0, n_obs), dtype=np.float16) if keep_f16 else None
|
|
269
|
+
b0 = g0
|
|
270
|
+
while b0 < g1:
|
|
271
|
+
# whole genes holding about ENTRY_BLOCK stored values (at least one gene)
|
|
272
|
+
target = min(int(kcum[b0]) + ENTRY_BLOCK, int(kcum[-1]))
|
|
273
|
+
b1 = max(b0 + 1, min(g1, int(np.searchsorted(kcum, target, side="right")) - 1))
|
|
274
|
+
n_vals = int(kcum[b1] - kcum[b0])
|
|
275
|
+
if n_vals:
|
|
276
|
+
lengths = nnz_col[kept[b0:b1]]
|
|
277
|
+
starts = Mc.indptr[kept[b0:b1]].astype(np.int64)
|
|
278
|
+
# position in Mc.data of every stored value of the block, column segments back to back
|
|
279
|
+
pos = np.arange(n_vals, dtype=np.int64) + np.repeat(starts - (np.cumsum(lengths) - lengths), lengths)
|
|
280
|
+
g = np.repeat(np.arange(b0, b1, dtype=np.int64), lengths) # kept-gene index per stored value
|
|
281
|
+
rows = Mc.indices[pos]
|
|
282
|
+
if inv is not None:
|
|
283
|
+
rows = inv[rows]
|
|
284
|
+
flat = (g - g0) * n_obs + rows
|
|
285
|
+
vals = Mc.data[pos]
|
|
286
|
+
q.reshape(-1)[flat] = quantize_values(vals, gmin[g], gmax[g])
|
|
287
|
+
if h is not None:
|
|
288
|
+
h.reshape(-1)[flat] = vals.astype(np.float16)
|
|
289
|
+
b0 = b1
|
|
290
|
+
writer.write(g0, q)
|
|
291
|
+
if f16 is not None:
|
|
292
|
+
f16[g0:g1, :] = h
|
|
293
|
+
log(f" expr genes {g1}/{G}")
|
|
294
|
+
writer.close()
|
|
295
|
+
|
|
296
|
+
root.create_array("gmax", shape=(G,), chunks=(max(G, 1),), dtype="float32", compressors=[ZstdCodec(level=3)])
|
|
297
|
+
root["gmax"][:] = gmax
|
|
298
|
+
root.create_array("gmin", shape=(G,), chunks=(max(G, 1),), dtype="float32", compressors=[ZstdCodec(level=3)])
|
|
299
|
+
root["gmin"][:] = gmin
|
|
300
|
+
return ExprResult(gmax=gmax, keep=keep, shard_genes=S, sharded=sharded, gmin=gmin)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Per-sample build fingerprints: how `build` decides whether a sample folder is still current.
|
|
2
|
+
|
|
3
|
+
After building a sample, `build` writes `samples/<id>/build.json` holding a fingerprint of everything that
|
|
4
|
+
went into it: the resolved sample settings, the size and modification time of every input file, the
|
|
5
|
+
declarations of the fields it maps (type, vocabulary, category order, aliases), the shard settings and the
|
|
6
|
+
format and recipe versions. The next `build` recomputes the fingerprint from the yaml and the files and
|
|
7
|
+
rebuilds the sample only when it differs. Display names, group labels and palette colors are not part of it:
|
|
8
|
+
they live in the dataset-level files, which every `build` rewrites.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .config import DatasetConfig, MicronsSpec, SampleSpec
|
|
20
|
+
from .discover import auto_images
|
|
21
|
+
from .manifest import FORMAT_VERSION
|
|
22
|
+
|
|
23
|
+
BUILD_RECIPE = 1 # bump when build_sample writes something different for the same inputs and settings
|
|
24
|
+
SIDECAR = "build.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _remote_stat(url: str) -> dict:
|
|
28
|
+
"""Size and mtime of an `ssh://host/path` file, asked over ssh (read-only); the URL alone when that fails."""
|
|
29
|
+
import subprocess
|
|
30
|
+
|
|
31
|
+
from .tracks import split_ssh_url
|
|
32
|
+
|
|
33
|
+
host, remote = split_ssh_url(url)
|
|
34
|
+
cmd = f"stat -c '%s %Y' {_q(remote)} 2>/dev/null || stat -f '%z %m' {_q(remote)}"
|
|
35
|
+
try:
|
|
36
|
+
out = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", host, cmd], capture_output=True, text=True, timeout=60)
|
|
37
|
+
size, mtime = out.stdout.split()[:2]
|
|
38
|
+
return {"size": int(size), "mtime_ns": int(mtime) * 1_000_000_000}
|
|
39
|
+
except Exception:
|
|
40
|
+
return {"remote": url}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _q(s: str) -> str:
|
|
44
|
+
import shlex
|
|
45
|
+
|
|
46
|
+
return shlex.quote(s)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _stat(path: Path | str) -> dict | None:
|
|
50
|
+
"""Size and mtime of a file, or a digest over the file list of a directory (Zarr stores, OME-Zarr images)."""
|
|
51
|
+
if isinstance(path, str) and path.startswith("ssh://"):
|
|
52
|
+
return _remote_stat(path)
|
|
53
|
+
p = Path(path)
|
|
54
|
+
if not p.exists():
|
|
55
|
+
return None
|
|
56
|
+
if p.is_file():
|
|
57
|
+
st = p.stat()
|
|
58
|
+
return {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
|
|
59
|
+
h = hashlib.sha1()
|
|
60
|
+
n = 0
|
|
61
|
+
for root, dirs, files in os.walk(p):
|
|
62
|
+
dirs.sort()
|
|
63
|
+
for f in sorted(files):
|
|
64
|
+
fp = Path(root) / f
|
|
65
|
+
try:
|
|
66
|
+
st = fp.stat()
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
h.update(f"{fp.relative_to(p)}\0{st.st_size}\0{st.st_mtime_ns}\n".encode())
|
|
70
|
+
n += 1
|
|
71
|
+
return {"tree": h.hexdigest(), "files": n}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_auto_images(spec: SampleSpec) -> SampleSpec:
|
|
75
|
+
"""`images: auto` and the micron rule the files next to the sample imply (SpaceRanger scale factors), resolved
|
|
76
|
+
the way build_sample does it. The scale is taken even when the images are listed or switched off (`images: []`),
|
|
77
|
+
so dropping a section's image does not lose its scale."""
|
|
78
|
+
auto = spec.images == "auto"
|
|
79
|
+
if not auto and spec.microns != MicronsSpec():
|
|
80
|
+
return spec
|
|
81
|
+
imgs, microns = auto_images(spec if auto else spec.model_copy(update={"images": "auto"}))
|
|
82
|
+
upd: dict = {"images": imgs} if auto else {}
|
|
83
|
+
if microns is not None and spec.microns == MicronsSpec():
|
|
84
|
+
upd["microns"] = microns
|
|
85
|
+
return spec.model_copy(update=upd) if upd else spec
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sample_fingerprint(spec: SampleSpec, cfg: DatasetConfig, *, sharded: bool) -> dict:
|
|
89
|
+
resolved = resolve_auto_images(spec)
|
|
90
|
+
spec_json = resolved.model_dump(mode="json", exclude={"name", "group"})
|
|
91
|
+
paths: list[Path | None] = [resolved.path, resolved.microns.scalefactors_json]
|
|
92
|
+
paths += [im.path for im in resolved.images]
|
|
93
|
+
if resolved.polygons is not None and resolved.polygons.path is not None:
|
|
94
|
+
paths.append(resolved.polygons.path)
|
|
95
|
+
if resolved.tracks is not None:
|
|
96
|
+
from .tracks import TRACKS_RECIPE
|
|
97
|
+
|
|
98
|
+
paths += resolved.tracks.input_files
|
|
99
|
+
spec_json["tracks"]["recipe"] = TRACKS_RECIPE # a changed track writer rebuilds only the samples that carry tracks
|
|
100
|
+
inputs = {str(p): _stat(p) for p in paths if p is not None}
|
|
101
|
+
fields = []
|
|
102
|
+
for fid in resolved.fields if isinstance(resolved.fields, dict) else {}:
|
|
103
|
+
f = cfg.field_spec(fid)
|
|
104
|
+
if f is not None:
|
|
105
|
+
fields.append({"id": f.id, "type": f.type, "vocabulary": f.vocabulary or f.id, "categories": f.categories, "aliases": f.aliases})
|
|
106
|
+
fp = {
|
|
107
|
+
"recipe": BUILD_RECIPE,
|
|
108
|
+
"format": FORMAT_VERSION,
|
|
109
|
+
"spec": spec_json,
|
|
110
|
+
"inputs": inputs,
|
|
111
|
+
"fields": fields,
|
|
112
|
+
"shard_genes": cfg.shard_genes,
|
|
113
|
+
"sharded": sharded,
|
|
114
|
+
}
|
|
115
|
+
return json.loads(json.dumps(fp, sort_keys=True, default=str)) # the same shape the sidecar comes back in
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def why_stale(old: dict | None, new: dict) -> str | None:
|
|
119
|
+
"""None when the sample folder is current, else a short reason for rebuilding it."""
|
|
120
|
+
if old is None:
|
|
121
|
+
return "new"
|
|
122
|
+
if old == new:
|
|
123
|
+
return None
|
|
124
|
+
if old.get("recipe") != new["recipe"] or old.get("format") != new["format"]:
|
|
125
|
+
return "format changed"
|
|
126
|
+
if old.get("inputs") != new["inputs"]:
|
|
127
|
+
return "source changed"
|
|
128
|
+
if old.get("sharded") != new["sharded"]:
|
|
129
|
+
return "sharding changed"
|
|
130
|
+
if old.get("shard_genes") != new["shard_genes"]:
|
|
131
|
+
return "shard size changed"
|
|
132
|
+
if old.get("spec") != new["spec"]:
|
|
133
|
+
return "settings changed"
|
|
134
|
+
if old.get("fields") != new["fields"]:
|
|
135
|
+
return "field declarations changed"
|
|
136
|
+
return "settings changed"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def read_sidecar(sample_dir: Path) -> dict | None:
|
|
140
|
+
p = Path(sample_dir) / SIDECAR
|
|
141
|
+
if not p.exists():
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return json.loads(p.read_text()).get("fingerprint")
|
|
145
|
+
except (OSError, ValueError, AttributeError):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def write_sidecar(sample_dir: Path, fingerprint: dict) -> Path:
|
|
150
|
+
p = Path(sample_dir) / SIDECAR
|
|
151
|
+
p.write_text(json.dumps({"fingerprint": fingerprint, "built": datetime.now(UTC).isoformat(timespec="seconds"), "spatialscape": __version__}, indent=1))
|
|
152
|
+
return p
|