spatialdata-js-util 0.1.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.
- spatialdata_js_util/__init__.py +107 -0
- spatialdata_js_util/cli.py +803 -0
- spatialdata_js_util/codecs/__init__.py +67 -0
- spatialdata_js_util/codecs/backends.py +250 -0
- spatialdata_js_util/codecs/chunks.py +40 -0
- spatialdata_js_util/codecs/encoding.py +154 -0
- spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
- spatialdata_js_util/codecs/names.py +26 -0
- spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
- spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
- spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
- spatialdata_js_util/codecs/vendor/__init__.py +1 -0
- spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
- spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
- spatialdata_js_util/codecs/zarr_codec.py +149 -0
- spatialdata_js_util/errors.py +5 -0
- spatialdata_js_util/images.py +849 -0
- spatialdata_js_util/index_permutations.py +311 -0
- spatialdata_js_util/points.py +445 -0
- spatialdata_js_util/points_zarr.py +799 -0
- spatialdata_js_util/provenance.py +17 -0
- spatialdata_js_util/pyramids.py +318 -0
- spatialdata_js_util/runners.py +458 -0
- spatialdata_js_util/store.py +305 -0
- spatialdata_js_util/tables.py +310 -0
- spatialdata_js_util/tui/__init__.py +0 -0
- spatialdata_js_util/tui/app.py +112 -0
- spatialdata_js_util/tui/models.py +45 -0
- spatialdata_js_util/tui/screens.py +1255 -0
- spatialdata_js_util/tui/widgets.py +50 -0
- spatialdata_js_util/verify.py +338 -0
- spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
- spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
- spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
- spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
- spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Version and checksum helpers recorded in output manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def package_version(name: str) -> str | None:
|
|
10
|
+
try:
|
|
11
|
+
return metadata.version(name)
|
|
12
|
+
except metadata.PackageNotFoundError:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sha256(data: bytes | bytearray) -> str:
|
|
17
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Add multiscale pyramids to raster elements that lack them.
|
|
2
|
+
|
|
3
|
+
Acquisition output is often written at a single resolution. A browser then has no
|
|
4
|
+
choice but to fetch full-resolution chunks no matter how far out the user is
|
|
5
|
+
zoomed, which is exactly the cost this package exists to avoid — so a store
|
|
6
|
+
without a pyramid defeats the image codecs rather than complementing them.
|
|
7
|
+
|
|
8
|
+
Levels are generated through SpatialData's own parsers rather than by writing
|
|
9
|
+
`multiscales` metadata by hand. Each level carries a scale *and* a half-pixel
|
|
10
|
+
translation (`s1` is offset by 0.5, `s2` by 1.5, …); getting those wrong
|
|
11
|
+
misaligns every level against the full-resolution image, and the parsers already
|
|
12
|
+
know the rule. Images are coarsened by averaging, labels by the label model's own
|
|
13
|
+
downsampler, so label ids are never blended into ones that never existed.
|
|
14
|
+
|
|
15
|
+
Rebuilding is always copy-then-write: the element is read from the source store
|
|
16
|
+
and written into a *different* store. SpatialData refuses to delete the files
|
|
17
|
+
backing a live element, so genuine in-place rewriting is not available here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import shutil
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING, Any, Literal, Sequence
|
|
26
|
+
|
|
27
|
+
from .errors import WriterCommandError
|
|
28
|
+
from .provenance import package_version
|
|
29
|
+
from .store import read_json, refresh_consolidated_metadata, write_json
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from spatialdata import SpatialData
|
|
33
|
+
|
|
34
|
+
RasterCollection = Literal["images", "labels"]
|
|
35
|
+
|
|
36
|
+
#: Stop halving once the largest spatial axis is no bigger than this. Matches the
|
|
37
|
+
#: default spatial chunk size, so the coarsest level is about one chunk.
|
|
38
|
+
DEFAULT_MIN_SIZE = 1024
|
|
39
|
+
|
|
40
|
+
#: Guard against pathological inputs generating an unbounded number of levels.
|
|
41
|
+
MAX_LEVELS = 12
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PyramidResult:
|
|
46
|
+
store_path: Path
|
|
47
|
+
manifest_path: Path | None
|
|
48
|
+
manifest: dict[str, Any]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _multiscale_datasets(store_path: Path, collection: str, key: str) -> list[str]:
|
|
52
|
+
"""Return the dataset paths (`s0`, `s1`, …) declared by a raster group."""
|
|
53
|
+
meta_path = store_path / collection / key / "zarr.json"
|
|
54
|
+
if not meta_path.is_file():
|
|
55
|
+
return []
|
|
56
|
+
attrs = read_json(meta_path).get("attributes", {})
|
|
57
|
+
ome = attrs.get("ome", attrs)
|
|
58
|
+
if not isinstance(ome, dict):
|
|
59
|
+
return []
|
|
60
|
+
multiscales = ome.get("multiscales")
|
|
61
|
+
if not multiscales:
|
|
62
|
+
return []
|
|
63
|
+
return [str(d["path"]) for d in multiscales[0].get("datasets", []) if "path" in d]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def has_pyramid(store_path: str | Path, collection: str, key: str) -> bool:
|
|
67
|
+
"""Whether a raster element already declares more than one resolution level."""
|
|
68
|
+
return len(_multiscale_datasets(Path(store_path), collection, key)) > 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _spatial_extent(element: Any) -> tuple[int, ...]:
|
|
72
|
+
"""Sizes of the spatial axes (y/x, plus z when present)."""
|
|
73
|
+
sizes = element.sizes
|
|
74
|
+
return tuple(int(sizes[axis]) for axis in ("z", "y", "x") if axis in sizes)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_scale_factors(
|
|
78
|
+
element: Any,
|
|
79
|
+
*,
|
|
80
|
+
levels: int | Literal["auto"] = "auto",
|
|
81
|
+
downscale: int = 2,
|
|
82
|
+
min_size: int = DEFAULT_MIN_SIZE,
|
|
83
|
+
) -> list[int]:
|
|
84
|
+
"""Return the per-step downscale factors to hand to a SpatialData parser.
|
|
85
|
+
|
|
86
|
+
`levels` counts *total* resolutions including full res, so `levels=3` yields
|
|
87
|
+
two factors and the levels `s0`, `s1`, `s2`. With ``"auto"``, halving
|
|
88
|
+
continues until the largest spatial axis fits within *min_size*.
|
|
89
|
+
"""
|
|
90
|
+
if downscale < 2:
|
|
91
|
+
raise WriterCommandError(f"Pyramid downscale must be at least 2, got {downscale}.")
|
|
92
|
+
|
|
93
|
+
extent = _spatial_extent(element)
|
|
94
|
+
if not extent:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
if levels != "auto":
|
|
98
|
+
if levels < 1:
|
|
99
|
+
raise WriterCommandError(f"Pyramid levels must be at least 1, got {levels}.")
|
|
100
|
+
# Silently capping an explicit request writes a pyramid the caller did not
|
|
101
|
+
# ask for and reports success; the cap is only implicit for `"auto"`.
|
|
102
|
+
if levels > MAX_LEVELS:
|
|
103
|
+
raise WriterCommandError(
|
|
104
|
+
f"Pyramid levels must be at most {MAX_LEVELS}, got {levels}."
|
|
105
|
+
)
|
|
106
|
+
return [downscale] * (int(levels) - 1)
|
|
107
|
+
|
|
108
|
+
if min_size < 1:
|
|
109
|
+
raise WriterCommandError(f"Pyramid min-size must be positive, got {min_size}.")
|
|
110
|
+
|
|
111
|
+
factors: list[int] = []
|
|
112
|
+
largest = max(extent)
|
|
113
|
+
while largest > min_size and len(factors) < MAX_LEVELS - 1:
|
|
114
|
+
factors.append(downscale)
|
|
115
|
+
largest //= downscale
|
|
116
|
+
# A further level would shrink an axis below one pixel.
|
|
117
|
+
if min(extent) // (downscale ** len(factors)) < 1:
|
|
118
|
+
break
|
|
119
|
+
return factors
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _full_resolution(element: Any) -> Any:
|
|
123
|
+
"""Return the full-resolution array, unwrapping an existing multiscale tree."""
|
|
124
|
+
from xarray import DataTree
|
|
125
|
+
|
|
126
|
+
if isinstance(element, DataTree):
|
|
127
|
+
first = next(iter(element))
|
|
128
|
+
return element[first]["image"]
|
|
129
|
+
return element
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rebuild_element(source_element: Any, scale_factors: list[int], chunks: Any) -> Any:
|
|
133
|
+
from spatialdata.models import get_model
|
|
134
|
+
|
|
135
|
+
model = get_model(source_element)
|
|
136
|
+
# Transformations travel with the element; passing them again is an error.
|
|
137
|
+
return model.parse(source_element, scale_factors=scale_factors, chunks=chunks)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _element_chunks(element: Any) -> tuple[int, ...] | None:
|
|
141
|
+
data = getattr(element, "data", None)
|
|
142
|
+
chunksize = getattr(data, "chunksize", None)
|
|
143
|
+
return tuple(int(size) for size in chunksize) if chunksize else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_pyramids_into(
|
|
147
|
+
*,
|
|
148
|
+
source_sdata: "SpatialData",
|
|
149
|
+
dest_sdata: "SpatialData",
|
|
150
|
+
keys: Sequence[str],
|
|
151
|
+
collection: RasterCollection,
|
|
152
|
+
levels: int | Literal["auto"] = "auto",
|
|
153
|
+
downscale: int = 2,
|
|
154
|
+
min_size: int = DEFAULT_MIN_SIZE,
|
|
155
|
+
force: bool = False,
|
|
156
|
+
) -> list[dict[str, Any]]:
|
|
157
|
+
"""Rebuild *keys* with pyramids, reading from one store and writing to another.
|
|
158
|
+
|
|
159
|
+
`dest_sdata` must be backed by a different path than `source_sdata`; see the
|
|
160
|
+
module docstring. Returns one report per key, including skipped ones.
|
|
161
|
+
"""
|
|
162
|
+
reports: list[dict[str, Any]] = []
|
|
163
|
+
dest_elements = getattr(dest_sdata, collection)
|
|
164
|
+
source_elements = getattr(source_sdata, collection)
|
|
165
|
+
|
|
166
|
+
for key in keys:
|
|
167
|
+
source_element = source_elements[key]
|
|
168
|
+
existing = _multiscale_datasets(Path(str(dest_sdata.path)), collection, key)
|
|
169
|
+
if len(existing) > 1 and not force:
|
|
170
|
+
reports.append(
|
|
171
|
+
{
|
|
172
|
+
"path": f"{collection}/{key}",
|
|
173
|
+
"action": "skipped",
|
|
174
|
+
"reason": "already multiscale",
|
|
175
|
+
"levels": existing,
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
full_res = _full_resolution(source_element)
|
|
181
|
+
scale_factors = resolve_scale_factors(
|
|
182
|
+
full_res, levels=levels, downscale=downscale, min_size=min_size
|
|
183
|
+
)
|
|
184
|
+
if not scale_factors:
|
|
185
|
+
reports.append(
|
|
186
|
+
{
|
|
187
|
+
"path": f"{collection}/{key}",
|
|
188
|
+
"action": "skipped",
|
|
189
|
+
"reason": (
|
|
190
|
+
"already at or below the target size"
|
|
191
|
+
if levels == "auto"
|
|
192
|
+
else "requested a single level"
|
|
193
|
+
),
|
|
194
|
+
"shape": list(full_res.shape),
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
rebuilt = _rebuild_element(full_res, scale_factors, _element_chunks(full_res))
|
|
200
|
+
dest_elements[key] = rebuilt
|
|
201
|
+
dest_sdata.delete_element_from_disk(key)
|
|
202
|
+
dest_sdata.write_element(key)
|
|
203
|
+
|
|
204
|
+
reports.append(
|
|
205
|
+
{
|
|
206
|
+
"path": f"{collection}/{key}",
|
|
207
|
+
"action": "rebuilt",
|
|
208
|
+
"shape": list(full_res.shape),
|
|
209
|
+
"scale_factors": scale_factors,
|
|
210
|
+
"levels": _multiscale_datasets(Path(str(dest_sdata.path)), collection, key),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
return reports
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _select_keys(
|
|
217
|
+
store_path: Path, collection: RasterCollection, requested: Sequence[str] | None
|
|
218
|
+
) -> list[str]:
|
|
219
|
+
from .store import list_element_keys
|
|
220
|
+
|
|
221
|
+
available = list_element_keys(store_path, collection)
|
|
222
|
+
if requested is None:
|
|
223
|
+
return available
|
|
224
|
+
unknown = [key for key in requested if key not in available]
|
|
225
|
+
if unknown:
|
|
226
|
+
raise WriterCommandError(
|
|
227
|
+
f"Unknown {collection} element(s): {', '.join(unknown)}. "
|
|
228
|
+
f"Available: {', '.join(available) or '(none)'}"
|
|
229
|
+
)
|
|
230
|
+
return list(requested)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def add_pyramids(
|
|
234
|
+
source: str | Path,
|
|
235
|
+
dest: str | Path,
|
|
236
|
+
*,
|
|
237
|
+
image_keys: Sequence[str] | None = None,
|
|
238
|
+
label_keys: Sequence[str] | None = None,
|
|
239
|
+
include_labels: bool = False,
|
|
240
|
+
levels: int | Literal["auto"] = "auto",
|
|
241
|
+
downscale: int = 2,
|
|
242
|
+
min_size: int = DEFAULT_MIN_SIZE,
|
|
243
|
+
force: bool = False,
|
|
244
|
+
overwrite: bool = False,
|
|
245
|
+
manifest: bool = True,
|
|
246
|
+
) -> PyramidResult:
|
|
247
|
+
"""Copy a store and give its raster elements multiscale pyramids.
|
|
248
|
+
|
|
249
|
+
Elements that already have more than one level are left alone unless *force*
|
|
250
|
+
is set. Everything the store contains that is not being rebuilt — points,
|
|
251
|
+
shapes, tables, other rasters — is preserved by the copy.
|
|
252
|
+
"""
|
|
253
|
+
import spatialdata as sd
|
|
254
|
+
|
|
255
|
+
source_path = Path(source)
|
|
256
|
+
dest_path = Path(dest)
|
|
257
|
+
if not (source_path / "zarr.json").is_file():
|
|
258
|
+
raise WriterCommandError(f"Not a zarr store (no zarr.json): {source_path}")
|
|
259
|
+
if dest_path.resolve() == source_path.resolve():
|
|
260
|
+
raise WriterCommandError(
|
|
261
|
+
"Pyramids must be written to a different store than the source: SpatialData "
|
|
262
|
+
"refuses to delete the files backing a live element."
|
|
263
|
+
)
|
|
264
|
+
if dest_path.exists():
|
|
265
|
+
if not overwrite:
|
|
266
|
+
raise WriterCommandError(
|
|
267
|
+
f"Destination already exists: {dest_path}\nPass overwrite to replace it."
|
|
268
|
+
)
|
|
269
|
+
shutil.rmtree(dest_path)
|
|
270
|
+
shutil.copytree(source_path, dest_path)
|
|
271
|
+
|
|
272
|
+
source_sdata = sd.read_zarr(source_path)
|
|
273
|
+
dest_sdata = sd.read_zarr(dest_path)
|
|
274
|
+
|
|
275
|
+
reports = build_pyramids_into(
|
|
276
|
+
source_sdata=source_sdata,
|
|
277
|
+
dest_sdata=dest_sdata,
|
|
278
|
+
keys=_select_keys(dest_path, "images", image_keys),
|
|
279
|
+
collection="images",
|
|
280
|
+
levels=levels,
|
|
281
|
+
downscale=downscale,
|
|
282
|
+
min_size=min_size,
|
|
283
|
+
force=force,
|
|
284
|
+
)
|
|
285
|
+
if include_labels:
|
|
286
|
+
reports.extend(
|
|
287
|
+
build_pyramids_into(
|
|
288
|
+
source_sdata=source_sdata,
|
|
289
|
+
dest_sdata=dest_sdata,
|
|
290
|
+
keys=_select_keys(dest_path, "labels", label_keys),
|
|
291
|
+
collection="labels",
|
|
292
|
+
levels=levels,
|
|
293
|
+
downscale=downscale,
|
|
294
|
+
min_size=min_size,
|
|
295
|
+
force=force,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
refresh_consolidated_metadata(dest_path)
|
|
300
|
+
|
|
301
|
+
manifest_data = {
|
|
302
|
+
"format": "spatialdata-pyramids/v1",
|
|
303
|
+
"source": str(source_path),
|
|
304
|
+
"output": str(dest_path),
|
|
305
|
+
"levels": levels,
|
|
306
|
+
"downscale": downscale,
|
|
307
|
+
"min_size": min_size,
|
|
308
|
+
"rasters": reports,
|
|
309
|
+
"packages": {
|
|
310
|
+
"spatialdata": package_version("spatialdata"),
|
|
311
|
+
"spatialdata-js-util": package_version("spatialdata-js-util"),
|
|
312
|
+
"zarr": package_version("zarr"),
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
manifest_path = dest_path.with_suffix(".pyramid-manifest.json") if manifest else None
|
|
316
|
+
if manifest_path is not None:
|
|
317
|
+
write_json(manifest_path, manifest_data)
|
|
318
|
+
return PyramidResult(dest_path, manifest_path, manifest_data)
|