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.
Files changed (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. 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)