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,849 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import tempfile
6
+ import warnings
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ import numpy as np
13
+ import zarr
14
+ from zarr.codecs import BloscCodec, BloscShuffle
15
+
16
+ from .codecs import (
17
+ CODEC_HTJ2K_OPENJPH,
18
+ CODEC_JPEG2K,
19
+ HTJ2K_ENCODER,
20
+ HTJ2K_QUALITY_FLOOR_LSB,
21
+ SUPPORTED_IMAGE_CODECS,
22
+ chunk_grid,
23
+ chunk_slices,
24
+ decode_image_plane,
25
+ dtype_quantum,
26
+ encode_image_plane,
27
+ is_htj2k_codec,
28
+ pad_chunk,
29
+ )
30
+ from .codecs.backends import (
31
+ BACKEND_OPENJPH_WASM,
32
+ backend_report,
33
+ htj2k_available,
34
+ resolve_backend,
35
+ )
36
+ from .codecs.htj2k_wasm import configure_encoder_pool
37
+ from .provenance import package_version, sha256
38
+ from .pyramids import DEFAULT_MIN_SIZE as DEFAULT_PYRAMID_MIN_SIZE
39
+ from .store import read_json, refresh_consolidated_metadata, write_json
40
+
41
+ ImagePreset = Literal["lossless", "balanced", "small"]
42
+ ChunkSpec = Literal["auto"] | tuple[int, ...] | list[int]
43
+
44
+ SUPPORTED_BROWSER_JP2K_DTYPES = {
45
+ np.dtype("uint8"),
46
+ np.dtype("int8"),
47
+ np.dtype("uint16"),
48
+ np.dtype("int16"),
49
+ }
50
+
51
+ JP2K_PRESETS: dict[ImagePreset, dict[str, Any]] = {
52
+ "lossless": {"reversible": True},
53
+ "balanced": {"reversible": False, "level": 100},
54
+ "small": {"reversible": False, "level": 75},
55
+ }
56
+
57
+ # Lossy HTJ2K presets are a multiple of the input's LSB, not an absolute step.
58
+ # OpenJPH's `quality` is normalised to the dtype's full range, so size and error
59
+ # both track the step measured in LSB — the same multiple costs the same on uint8
60
+ # and uint16. Below ~1 LSB (`HTJ2K_QUALITY_FLOOR_LSB`) the irreversible path is
61
+ # strictly dominated: it returns a bit-identical image for more bytes than
62
+ # reversible. See docs/htj2k-wasm-encode-design.md for the measurements.
63
+ HTJ2K_PRESETS: dict[ImagePreset, dict[str, Any]] = {
64
+ "lossless": {"reversible": True},
65
+ # Mean error ~0.6 LSB, p99 2 LSB.
66
+ "balanced": {"reversible": False, "quality_lsb": 2.0},
67
+ # Mean error ~1.6 LSB, p99 5 LSB.
68
+ "small": {"reversible": False, "quality_lsb": 5.0},
69
+ }
70
+
71
+
72
+ def htj2k_preset_quality(preset: ImagePreset, dtype: np.dtype) -> float | None:
73
+ """Resolve an HTJ2K preset to a quantization step, or ``None`` if reversible."""
74
+ quality_lsb = HTJ2K_PRESETS[preset].get("quality_lsb")
75
+ if quality_lsb is None:
76
+ return None
77
+ return float(quality_lsb) * dtype_quantum(dtype)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class RecompressedSpatialData:
82
+ store_path: Path
83
+ manifest_path: Path | None
84
+ manifest: dict[str, Any]
85
+
86
+
87
+ def _load_config(config: str | Path | dict[str, Any] | None) -> dict[str, Any]:
88
+ if config is None:
89
+ return {}
90
+ if isinstance(config, dict):
91
+ return json.loads(json.dumps(config))
92
+ return read_json(Path(config))
93
+
94
+
95
+ def _default_config() -> dict[str, Any]:
96
+ return {
97
+ "default_image": {
98
+ "codec": CODEC_JPEG2K,
99
+ "preset": "lossless",
100
+ "chunks": "auto",
101
+ },
102
+ "images": {},
103
+ "default_labels": {
104
+ "codec": "blosc",
105
+ "clevel": 5,
106
+ },
107
+ "labels": {},
108
+ }
109
+
110
+
111
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
112
+ out = dict(base)
113
+ for key, value in override.items():
114
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
115
+ out[key] = _deep_merge(out[key], value)
116
+ else:
117
+ out[key] = value
118
+ return out
119
+
120
+
121
+ def _apply_image_shortcuts(
122
+ image_cfg: dict[str, Any],
123
+ *,
124
+ codec: str | None = None,
125
+ preset: ImagePreset | None = None,
126
+ chunks: ChunkSpec | None = None,
127
+ quality: float | None = None,
128
+ reversible: bool | None = None,
129
+ ) -> dict[str, Any]:
130
+ out = dict(image_cfg)
131
+ if codec is not None:
132
+ out["codec"] = codec
133
+ if preset is not None:
134
+ out["preset"] = preset
135
+ if chunks is not None:
136
+ out["chunks"] = chunks
137
+ if quality is not None:
138
+ out["quality"] = quality
139
+ if reversible is not None:
140
+ out["reversible"] = reversible
141
+ return out
142
+
143
+
144
+ def resolve_recompression_config(
145
+ config: str | Path | dict[str, Any] | None = None,
146
+ *,
147
+ image_key: str | None = None,
148
+ codec: str | None = None,
149
+ preset: ImagePreset | None = None,
150
+ chunks: ChunkSpec | None = None,
151
+ quality: float | None = None,
152
+ reversible: bool | None = None,
153
+ ) -> dict[str, Any]:
154
+ """Return normalized recompression config after CLI shortcut expansion.
155
+
156
+ When *image_key* is set, shortcut flags apply to that image only. Otherwise
157
+ they update ``default_image`` and apply to every image in the store.
158
+ """
159
+
160
+ resolved = _deep_merge(_default_config(), _load_config(config))
161
+ shortcuts = _apply_image_shortcuts(
162
+ {},
163
+ codec=codec,
164
+ preset=preset,
165
+ chunks=chunks,
166
+ quality=quality,
167
+ reversible=reversible,
168
+ )
169
+ if image_key is not None:
170
+ image_cfg = _apply_image_shortcuts(
171
+ dict(resolved.get("images", {}).get(image_key, {})),
172
+ codec=codec,
173
+ preset=preset,
174
+ chunks=chunks,
175
+ quality=quality,
176
+ reversible=reversible,
177
+ )
178
+ resolved.setdefault("images", {})[image_key] = image_cfg
179
+ elif shortcuts:
180
+ resolved["default_image"] = _deep_merge(resolved.get("default_image", {}), shortcuts)
181
+ return resolved
182
+
183
+
184
+ def _list_raster_keys(store_path: Path, kind: Literal["images", "labels"]) -> list[str]:
185
+ root = store_path / kind
186
+ if not root.exists():
187
+ return []
188
+ return sorted(path.name for path in root.iterdir() if path.is_dir())
189
+
190
+
191
+ def _attrs_from_node(path: Path) -> dict[str, Any]:
192
+ meta_path = path / "zarr.json"
193
+ if not meta_path.exists():
194
+ return {}
195
+ return read_json(meta_path).get("attributes", {})
196
+
197
+
198
+ def _datasets_from_raster_group(path: Path) -> list[str]:
199
+ attrs = _attrs_from_node(path)
200
+ ome_attrs = attrs.get("ome", attrs)
201
+ if isinstance(ome_attrs, dict):
202
+ multiscales = ome_attrs.get("multiscales")
203
+ datasets = multiscales[0].get("datasets") if multiscales else None
204
+ if datasets:
205
+ return [str(dataset["path"]) for dataset in datasets if "path" in dataset]
206
+
207
+ levels = []
208
+ for child in path.iterdir() if path.exists() else []:
209
+ if child.is_dir() and (child / "zarr.json").exists():
210
+ meta = read_json(child / "zarr.json")
211
+ if meta.get("node_type") == "array":
212
+ levels.append(child.name)
213
+ return sorted(levels, key=lambda value: (not value.isdigit(), value))
214
+
215
+
216
+ def _auto_image_chunks(shape: tuple[int, ...], max_spatial: int = 1024) -> tuple[int, ...]:
217
+ if len(shape) < 2:
218
+ raise ValueError(f"Expected at least 2D raster shape, got {shape}")
219
+ return (*([1] * (len(shape) - 2)), min(max_spatial, shape[-2]), min(max_spatial, shape[-1]))
220
+
221
+
222
+ def _normalize_chunks(spec: ChunkSpec, shape: tuple[int, ...], *, image: bool) -> tuple[int, ...]:
223
+ if spec == "auto":
224
+ return _auto_image_chunks(shape) if image else tuple(min(size, 1024) for size in shape)
225
+ chunks = tuple(int(value) for value in spec)
226
+ if len(chunks) != len(shape):
227
+ raise ValueError(f"Chunk spec {chunks} does not match raster shape {shape}")
228
+ return chunks
229
+
230
+
231
+ def _validate_browser_image_codec_dtype(dtype: np.dtype, raster_path: str) -> None:
232
+ if dtype not in SUPPORTED_BROWSER_JP2K_DTYPES:
233
+ supported = ", ".join(
234
+ str(dtype) for dtype in sorted(SUPPORTED_BROWSER_JP2K_DTYPES, key=str)
235
+ )
236
+ raise TypeError(
237
+ f"Browser image codecs support only <=16-bit integer dtypes ({supported}); "
238
+ f"{raster_path} has dtype {dtype}. Use Blosc for labels or skip this raster."
239
+ )
240
+
241
+
242
+ def _validate_browser_image_codec_chunks(chunks: tuple[int, ...], raster_path: str) -> None:
243
+ if len(chunks) < 2:
244
+ raise ValueError(f"{raster_path} must be at least 2D")
245
+ non_spatial = chunks[:-2]
246
+ if any(chunk != 1 for chunk in non_spatial):
247
+ raise ValueError(
248
+ f"{raster_path} image codec chunks must have singleton non-spatial axes; got {chunks}."
249
+ )
250
+
251
+
252
+ def _resolve_image_codec(config: dict[str, Any], raster_key: str) -> str:
253
+ codec = config.get("codec", CODEC_JPEG2K)
254
+ if codec not in SUPPORTED_IMAGE_CODECS:
255
+ supported = ", ".join(sorted(SUPPORTED_IMAGE_CODECS))
256
+ raise ValueError(
257
+ f"Unsupported image codec for {raster_key!r}: {codec!r}; expected one of {supported}"
258
+ )
259
+ if codec == CODEC_HTJ2K_OPENJPH and not htj2k_available():
260
+ raise RuntimeError(
261
+ f"HTJ2K recompression requested for {raster_key!r}, but no HTJ2K backend is "
262
+ "available. Install `imagecodecs` with HTJ2K support, or put Node.js on PATH "
263
+ "to use the vendored openjph-wasm backend."
264
+ )
265
+ return codec
266
+
267
+
268
+ def _array_metadata_from_source(
269
+ source_meta: dict[str, Any],
270
+ *,
271
+ chunks: tuple[int, ...],
272
+ codecs: list[dict[str, Any]],
273
+ ) -> dict[str, Any]:
274
+ meta = dict(source_meta)
275
+ meta["chunk_grid"] = {
276
+ "name": "regular",
277
+ "configuration": {"chunk_shape": list(chunks)},
278
+ }
279
+ meta["chunk_key_encoding"] = {
280
+ "name": "default",
281
+ "configuration": {"separator": "/"},
282
+ }
283
+ meta["codecs"] = codecs
284
+ return meta
285
+
286
+
287
+ def _preset_encode_options(
288
+ config: dict[str, Any], *, codec: str, dtype: np.dtype | None = None
289
+ ) -> dict[str, Any]:
290
+ """Resolve config plus preset into concrete encode options.
291
+
292
+ HTJ2K presets are bit-depth relative, so *dtype* is required to resolve one;
293
+ it also lets an explicit ``quality`` be checked against the input's LSB.
294
+ """
295
+ presets = HTJ2K_PRESETS if codec == CODEC_HTJ2K_OPENJPH else JP2K_PRESETS
296
+ preset_family = "HTJ2K" if codec == CODEC_HTJ2K_OPENJPH else "JP2K"
297
+ encode_options = dict(config.get("encode_options", {}))
298
+
299
+ preset = config.get("preset")
300
+ has_explicit_quality = "quality" in config or "quality" in encode_options
301
+ if preset is not None:
302
+ if preset not in presets:
303
+ raise ValueError(
304
+ f"Unknown {preset_family} preset {preset!r}; expected one of {sorted(presets)}"
305
+ )
306
+ options = dict(presets[preset])
307
+ elif has_explicit_quality and codec == CODEC_HTJ2K_OPENJPH:
308
+ options = {}
309
+ else:
310
+ options = dict(presets["lossless"])
311
+
312
+ options.update(encode_options)
313
+ for key in ("level", "quality", "reversible", "codecformat", "numthreads"):
314
+ if key in config:
315
+ options[key] = config[key]
316
+
317
+ explicit_lossless = config.get("reversible") is True or encode_options.get("reversible") is True
318
+
319
+ # An explicit `quality` overrides the preset, including its LSB multiple.
320
+ preset_quality_lsb = options.pop("quality_lsb", None)
321
+ if preset_quality_lsb is not None and options.get("quality") is None:
322
+ if dtype is None:
323
+ described = f"{preset!r} preset" if preset is not None else "quality_lsb setting"
324
+ raise ValueError(
325
+ f"Resolving the HTJ2K {described} needs the input dtype: the "
326
+ "quantization step is relative to the input's bit depth."
327
+ )
328
+ options["quality"] = float(preset_quality_lsb) * dtype_quantum(dtype)
329
+
330
+ if options.get("quality") is not None and not explicit_lossless:
331
+ options["reversible"] = False
332
+ _warn_if_quality_below_input_resolution(options["quality"], dtype)
333
+
334
+ return options
335
+
336
+
337
+ def _warn_if_quality_below_input_resolution(quality: float, dtype: np.dtype | None) -> None:
338
+ """Warn when a step is finer than one input LSB, which encodes larger than
339
+ lossless for a bit-identical image. Only an explicit ``quality`` can ask for
340
+ this; presets stay above the floor."""
341
+ # Nothing to compare against for a dtype with no LSB (see `dtype_quantum`);
342
+ # an explicit step stays legal there, so this stays silent rather than raising.
343
+ if dtype is None or dtype.kind not in ("i", "u"):
344
+ return
345
+ floor = HTJ2K_QUALITY_FLOOR_LSB * dtype_quantum(dtype)
346
+ if quality >= floor:
347
+ return
348
+ warnings.warn(
349
+ f"HTJ2K quality={quality:g} is finer than one {dtype} LSB ({dtype_quantum(dtype):g}); "
350
+ f"the irreversible transform will return a bit-identical image while encoding "
351
+ f"LARGER than lossless. Use quality>={floor:g} for a genuinely smaller file, "
352
+ f"or reversible=True (preset 'lossless') for the smallest exact one.",
353
+ UserWarning,
354
+ stacklevel=3,
355
+ )
356
+
357
+
358
+ def _sibling_image_label(image_config: dict[str, Any]) -> str:
359
+ """Return a sibling suffix label from preset and/or explicit encode settings."""
360
+ encode_options = image_config.get("encode_options", {})
361
+ quality = image_config.get("quality", encode_options.get("quality"))
362
+ if quality is not None and image_config.get("reversible") is not True:
363
+ return f"q{float(quality):g}"
364
+ preset = image_config.get("preset", "lossless")
365
+ return str(preset)
366
+
367
+
368
+ def _sample_values(plane: np.ndarray) -> list[int | float]:
369
+ values = [
370
+ plane[0, 0],
371
+ plane[0, min(1, plane.shape[1] - 1)],
372
+ plane[min(1, plane.shape[0] - 1), 0],
373
+ ]
374
+ return [value.item() if hasattr(value, "item") else value for value in values]
375
+
376
+
377
+ def _encode_chunk_task(
378
+ plane: np.ndarray, codec: str, encode_options: dict[str, Any]
379
+ ) -> bytes | bytearray:
380
+ return encode_image_plane(plane, codec, encode_options)
381
+
382
+
383
+ def _chunk_plane(
384
+ source_array: Any, shape: tuple[int, ...], chunks: tuple[int, ...], coords: tuple[int, ...]
385
+ ) -> np.ndarray:
386
+ selection = chunk_slices(shape, chunks, coords)
387
+ chunk = pad_chunk(np.asarray(source_array[selection]), chunks)
388
+ return chunk.reshape(chunk.shape[-2], chunk.shape[-1])
389
+
390
+
391
+ def _write_encoded_chunk(
392
+ dest_array_path: Path, coords: tuple[int, ...], encoded: bytes | bytearray
393
+ ) -> None:
394
+ chunk_rel = Path("c").joinpath(*(str(coord) for coord in coords))
395
+ chunk_path = dest_array_path / chunk_rel
396
+ chunk_path.parent.mkdir(parents=True, exist_ok=True)
397
+ chunk_path.write_bytes(encoded)
398
+
399
+
400
+ def _maybe_record_chunk_check(
401
+ *,
402
+ chunks_checked: list[dict[str, Any]],
403
+ coords: tuple[int, ...],
404
+ encoded: bytes | bytearray,
405
+ plane: np.ndarray,
406
+ codec: str,
407
+ raster_path: str,
408
+ is_lossless: bool,
409
+ ) -> None:
410
+ if len(chunks_checked) >= 4:
411
+ return
412
+ decoded = decode_image_plane(encoded, codec)
413
+ decoded_plane = decoded.reshape(plane.shape)
414
+ if is_lossless and not np.array_equal(decoded_plane, plane):
415
+ raise RuntimeError(
416
+ f"Lossless {codec} self-validation failed for {raster_path} chunk {coords}"
417
+ )
418
+ chunks_checked.append(
419
+ {
420
+ "coords": list(coords),
421
+ "encoded_sha256": sha256(encoded),
422
+ "decoded_sha256": sha256(decoded_plane.tobytes()),
423
+ "source_sha256": sha256(plane.tobytes()),
424
+ "samples": _sample_values(decoded_plane),
425
+ }
426
+ )
427
+
428
+
429
+ def _recompress_image_array(
430
+ *,
431
+ source_array_path: Path,
432
+ dest_array_path: Path,
433
+ raster_path: str,
434
+ config: dict[str, Any],
435
+ workers: int,
436
+ ) -> dict[str, Any]:
437
+ source_meta = read_json(source_array_path / "zarr.json")
438
+ source_array = zarr.open_array(str(source_array_path), mode="r")
439
+ shape = tuple(int(value) for value in source_array.shape)
440
+ dtype = np.dtype(source_array.dtype)
441
+ codec = _resolve_image_codec(config, raster_path)
442
+ _validate_browser_image_codec_dtype(dtype, raster_path)
443
+
444
+ chunks = _normalize_chunks(config.get("chunks", "auto"), shape, image=True)
445
+ _validate_browser_image_codec_chunks(chunks, raster_path)
446
+ encode_options = _preset_encode_options(config, codec=codec, dtype=dtype)
447
+ is_lossless = bool(encode_options.get("reversible", False))
448
+
449
+ if dest_array_path.exists():
450
+ shutil.rmtree(dest_array_path)
451
+ (dest_array_path / "c").mkdir(parents=True)
452
+ write_json(
453
+ dest_array_path / "zarr.json",
454
+ _array_metadata_from_source(
455
+ source_meta,
456
+ chunks=chunks,
457
+ codecs=[{"name": codec, "configuration": {}}],
458
+ ),
459
+ )
460
+
461
+ chunk_coords_list = list(chunk_grid(shape, chunks))
462
+ chunks_checked: list[dict[str, Any]] = []
463
+ encoded_bytes = 0
464
+ chunk_count = 0
465
+
466
+ if workers <= 1 or len(chunk_coords_list) <= 1:
467
+ for coords in chunk_coords_list:
468
+ plane = _chunk_plane(source_array, shape, chunks, coords)
469
+ encoded = _encode_chunk_task(plane, codec, encode_options)
470
+ encoded_bytes += len(encoded)
471
+ chunk_count += 1
472
+ _write_encoded_chunk(dest_array_path, coords, encoded)
473
+ _maybe_record_chunk_check(
474
+ chunks_checked=chunks_checked,
475
+ coords=coords,
476
+ encoded=encoded,
477
+ plane=plane,
478
+ codec=codec,
479
+ raster_path=raster_path,
480
+ is_lossless=is_lossless,
481
+ )
482
+ else:
483
+ with ThreadPoolExecutor(max_workers=workers) as executor:
484
+ future_to_job = {
485
+ executor.submit(_encode_chunk_task, plane, codec, encode_options): (coords, plane)
486
+ for coords in chunk_coords_list
487
+ for plane in [_chunk_plane(source_array, shape, chunks, coords)]
488
+ }
489
+ for future in as_completed(future_to_job):
490
+ coords, plane = future_to_job[future]
491
+ encoded = future.result()
492
+ encoded_bytes += len(encoded)
493
+ chunk_count += 1
494
+ _write_encoded_chunk(dest_array_path, coords, encoded)
495
+ _maybe_record_chunk_check(
496
+ chunks_checked=chunks_checked,
497
+ coords=coords,
498
+ encoded=encoded,
499
+ plane=plane,
500
+ codec=codec,
501
+ raster_path=raster_path,
502
+ is_lossless=is_lossless,
503
+ )
504
+
505
+ report: dict[str, Any] = {
506
+ "path": raster_path,
507
+ "codec": codec,
508
+ "preset": config.get("preset", "lossless"),
509
+ "encode_options": encode_options,
510
+ "shape": list(shape),
511
+ "dtype": str(dtype),
512
+ "chunks": list(chunks),
513
+ "chunk_count": chunk_count,
514
+ "encoded_bytes": encoded_bytes,
515
+ "lossless": is_lossless,
516
+ "chunks_checked": chunks_checked,
517
+ }
518
+ if is_htj2k_codec(codec):
519
+ backend = resolve_backend()
520
+ report["encoder"] = HTJ2K_ENCODER
521
+ report["encoder_backend"] = backend.name if backend else None
522
+ return report
523
+
524
+
525
+ def _blosc_codec(dtype: np.dtype, clevel: int) -> BloscCodec:
526
+ if dtype.kind not in ("u", "i"):
527
+ raise TypeError(f"Labels must be integer dtype for Blosc label compression, got {dtype}")
528
+ return BloscCodec(
529
+ cname="zstd",
530
+ clevel=clevel,
531
+ shuffle=BloscShuffle.shuffle,
532
+ typesize=max(1, dtype.itemsize),
533
+ )
534
+
535
+
536
+ def _recompress_label_array(
537
+ *,
538
+ source_array_path: Path,
539
+ dest_array_path: Path,
540
+ raster_path: str,
541
+ config: dict[str, Any],
542
+ ) -> dict[str, Any]:
543
+ source_meta = read_json(source_array_path / "zarr.json")
544
+ source_array = zarr.open_array(str(source_array_path), mode="r")
545
+ shape = tuple(int(value) for value in source_array.shape)
546
+ dtype = np.dtype(source_array.dtype)
547
+ chunks = _normalize_chunks(config.get("chunks", source_array.chunks), shape, image=False)
548
+ codec = _blosc_codec(dtype, int(config.get("clevel", 5)))
549
+
550
+ if dest_array_path.exists():
551
+ shutil.rmtree(dest_array_path)
552
+ dest_array_path.mkdir(parents=True)
553
+ dest_array = zarr.create_array(
554
+ store=str(dest_array_path),
555
+ shape=shape,
556
+ chunks=chunks,
557
+ dtype=dtype,
558
+ compressors=[codec],
559
+ fill_value=source_meta.get("fill_value", 0),
560
+ attributes=source_meta.get("attributes", {}),
561
+ dimension_names=source_meta.get("dimension_names"),
562
+ zarr_format=3,
563
+ overwrite=True,
564
+ )
565
+
566
+ chunk_count = 0
567
+ for coords in chunk_grid(shape, chunks):
568
+ selection = chunk_slices(shape, chunks, coords)
569
+ dest_array[selection] = source_array[selection]
570
+ chunk_count += 1
571
+
572
+ meta = read_json(dest_array_path / "zarr.json")
573
+ return {
574
+ "path": raster_path,
575
+ "codec": "blosc",
576
+ "shape": list(shape),
577
+ "dtype": str(dtype),
578
+ "chunks": list(chunks),
579
+ "chunk_count": chunk_count,
580
+ "codecs": meta.get("codecs", []),
581
+ }
582
+
583
+
584
+ def _prepare_path_source(source_path: Path, dest: Path, *, overwrite: bool) -> Path:
585
+ if dest.exists():
586
+ if not overwrite:
587
+ raise FileExistsError(dest)
588
+ shutil.rmtree(dest)
589
+
590
+ shutil.copytree(source_path, dest)
591
+ return source_path
592
+
593
+
594
+ def _codec_sibling_suffix(codec: str) -> str:
595
+ if is_htj2k_codec(codec):
596
+ return "htj2k"
597
+ return "jp2k"
598
+
599
+
600
+ def _sibling_image_key(key: str, codec: str, preset: str) -> str:
601
+ """Return the sibling image key, e.g. ``morphology:jp2k_lossless``."""
602
+ return f"{key}:{_codec_sibling_suffix(codec)}_{preset}"
603
+
604
+
605
+ def _stage_pyramids(
606
+ *,
607
+ read_path: Path,
608
+ staging_path: Path,
609
+ image_keys: list[str],
610
+ levels: int | Literal["auto"],
611
+ downscale: int,
612
+ min_size: int,
613
+ force: bool,
614
+ ) -> tuple[list[dict[str, Any]], dict[str, Path]]:
615
+ """Build pyramids for *image_keys* into a staging store.
616
+
617
+ Returns the per-raster reports and, for each key that was actually rebuilt,
618
+ the root to read its levels from.
619
+ """
620
+ import spatialdata as sd
621
+
622
+ from .pyramids import build_pyramids_into
623
+
624
+ shutil.copytree(read_path, staging_path)
625
+ reports = build_pyramids_into(
626
+ source_sdata=sd.read_zarr(read_path),
627
+ dest_sdata=sd.read_zarr(staging_path),
628
+ keys=image_keys,
629
+ collection="images",
630
+ levels=levels,
631
+ downscale=downscale,
632
+ min_size=min_size,
633
+ force=force,
634
+ )
635
+ roots = {
636
+ report["path"].split("/", 1)[1]: staging_path
637
+ for report in reports
638
+ if report.get("action") == "rebuilt"
639
+ }
640
+ return reports, roots
641
+
642
+
643
+ def recompress_spatialdata(
644
+ source: str | Path | Any,
645
+ dest: str | Path,
646
+ *,
647
+ config: str | Path | dict[str, Any] | None = None,
648
+ overwrite: bool = False,
649
+ manifest: bool = True,
650
+ image_key: str | None = None,
651
+ codec: str | None = None,
652
+ preset: ImagePreset | None = None,
653
+ chunks: ChunkSpec | None = None,
654
+ quality: float | None = None,
655
+ reversible: bool | None = None,
656
+ sibling: bool = False,
657
+ workers: int | None = None,
658
+ pyramid: bool = False,
659
+ pyramid_levels: int | Literal["auto"] = "auto",
660
+ pyramid_downscale: int = 2,
661
+ pyramid_min_size: int = DEFAULT_PYRAMID_MIN_SIZE,
662
+ pyramid_force: bool = False,
663
+ ) -> RecompressedSpatialData:
664
+ """Preserve a SpatialData store and recompress configured rasters.
665
+
666
+ When *sibling* is ``False`` (default) each configured image is rewritten
667
+ in-place with the new codec. When *sibling* is ``True`` the original
668
+ image is kept and a new image group is added alongside it whose name is
669
+ ``{original_key}:{codec}_{preset}`` (e.g. ``morphology_focus:jp2k_lossless``
670
+ or ``morphology_focus:htj2k_balanced``). This lets the original remain
671
+ available for tools that lack the target codec while the compressed version
672
+ is used where it is supported.
673
+
674
+ Path sources are copied before raster replacement, which keeps tables,
675
+ shapes, points, and unconfigured rasters intact without loading the whole
676
+ object.
677
+
678
+ With *pyramid*, any configured image that has only one resolution level is
679
+ given a multiscale pyramid before it is recompressed, so single-resolution
680
+ acquisition output becomes browser-ready in one pass. Images that already
681
+ have a pyramid are left alone unless *pyramid_force* is set. See
682
+ `spatialdata_js_util.pyramids` for how levels are chosen.
683
+ """
684
+
685
+ import os
686
+
687
+ worker_count = workers if workers is not None else (os.cpu_count() or 1)
688
+ # Only the WASM backend has a worker pool to size; `imagecodecs` releases the
689
+ # GIL in its own encoder, so the ThreadPoolExecutor below is enough.
690
+ selected = resolve_backend()
691
+ if selected is not None and selected.name == BACKEND_OPENJPH_WASM:
692
+ configure_encoder_pool(worker_count)
693
+
694
+ dest_path = Path(dest)
695
+ temp_dir: tempfile.TemporaryDirectory[str] | None = None
696
+ pyramid_dir: tempfile.TemporaryDirectory[str] | None = None
697
+ try:
698
+ if isinstance(source, str | Path):
699
+ read_path = _prepare_path_source(Path(source), dest_path, overwrite=overwrite)
700
+ source_label = str(source)
701
+ else:
702
+ if dest_path.exists():
703
+ if not overwrite:
704
+ raise FileExistsError(dest_path)
705
+ shutil.rmtree(dest_path)
706
+ temp_dir = tempfile.TemporaryDirectory(prefix="spatialdata-codec-writer-")
707
+ read_path = Path(temp_dir.name) / "source.zarr"
708
+ source.write(str(read_path), overwrite=True)
709
+ shutil.copytree(read_path, dest_path)
710
+ source_label = None
711
+
712
+ resolved_config = resolve_recompression_config(
713
+ config,
714
+ image_key=image_key,
715
+ codec=codec,
716
+ preset=preset,
717
+ chunks=chunks,
718
+ quality=quality,
719
+ reversible=reversible,
720
+ )
721
+
722
+ image_keys = list(resolved_config.get("images") or {})
723
+ if not image_keys:
724
+ image_keys = _list_raster_keys(dest_path, "images")
725
+
726
+ label_keys = list(resolved_config.get("labels") or {})
727
+ if not label_keys and resolved_config.get("default_labels", {}).get("codec") == "blosc":
728
+ label_keys = _list_raster_keys(dest_path, "labels")
729
+
730
+ # Pyramids are staged in their own store rather than written straight into
731
+ # dest: the recompressor deletes a destination array before streaming the
732
+ # source into it, so reading the new levels from dest would pull the rug
733
+ # out from under itself.
734
+ pyramid_reports: list[dict[str, Any]] = []
735
+ pyramid_roots: dict[str, Path] = {}
736
+ if pyramid:
737
+ pyramid_dir = tempfile.TemporaryDirectory(prefix="spatialdata-js-util-pyramid-")
738
+ pyramid_reports, pyramid_roots = _stage_pyramids(
739
+ read_path=read_path,
740
+ staging_path=Path(pyramid_dir.name) / "pyramid.zarr",
741
+ image_keys=image_keys,
742
+ levels=pyramid_levels,
743
+ downscale=pyramid_downscale,
744
+ min_size=pyramid_min_size,
745
+ force=pyramid_force,
746
+ )
747
+
748
+ image_reports = []
749
+ default_image = resolved_config.get("default_image", {})
750
+ for key in image_keys:
751
+ image_config = _deep_merge(default_image, resolved_config.get("images", {}).get(key, {}))
752
+ resolved_codec = _resolve_image_codec(image_config, key)
753
+ # Rebuilt images are read from staging; everything else from the source.
754
+ raster_root = pyramid_roots.get(key, read_path)
755
+
756
+ if sibling:
757
+ dest_key = _sibling_image_key(
758
+ key,
759
+ resolved_codec,
760
+ _sibling_image_label(image_config),
761
+ )
762
+ sib_group_path = dest_path / "images" / dest_key
763
+ if sib_group_path.exists():
764
+ shutil.rmtree(sib_group_path)
765
+ sib_group_path.mkdir(parents=True)
766
+ src_group_meta = raster_root / "images" / key / "zarr.json"
767
+ if src_group_meta.exists():
768
+ shutil.copy2(src_group_meta, sib_group_path / "zarr.json")
769
+ else:
770
+ dest_key = key
771
+ if key in pyramid_roots:
772
+ # Replace the copied single-level group wholesale so the store
773
+ # declares the new levels and keeps no stale ones.
774
+ dest_group = dest_path / "images" / dest_key
775
+ if dest_group.exists():
776
+ shutil.rmtree(dest_group)
777
+ dest_group.mkdir(parents=True)
778
+ shutil.copy2(
779
+ raster_root / "images" / key / "zarr.json", dest_group / "zarr.json"
780
+ )
781
+
782
+ group_for_datasets = (
783
+ raster_root / "images" / key
784
+ if sibling or key in pyramid_roots
785
+ else dest_path / "images" / key
786
+ )
787
+ for dataset in _datasets_from_raster_group(group_for_datasets):
788
+ source_raster = f"images/{key}/{dataset}"
789
+ dest_raster = f"images/{dest_key}/{dataset}"
790
+ image_reports.append(
791
+ _recompress_image_array(
792
+ source_array_path=raster_root / source_raster,
793
+ dest_array_path=dest_path / dest_raster,
794
+ raster_path=dest_raster,
795
+ config=image_config,
796
+ workers=worker_count,
797
+ )
798
+ )
799
+
800
+ label_reports = []
801
+ default_label = resolved_config.get("default_labels", {})
802
+ if default_label.get("codec", "blosc") not in {"blosc", None}:
803
+ raise ValueError(f"Unsupported label codec: {default_label.get('codec')!r}")
804
+ for key in label_keys:
805
+ label_config = _deep_merge(default_label, resolved_config.get("labels", {}).get(key, {}))
806
+ if label_config.get("codec", "blosc") != "blosc":
807
+ raise ValueError(
808
+ f"Labels only support Blosc compression in v1, got {label_config.get('codec')!r}"
809
+ )
810
+ for dataset in _datasets_from_raster_group(dest_path / "labels" / key):
811
+ raster_path = f"labels/{key}/{dataset}"
812
+ label_reports.append(
813
+ _recompress_label_array(
814
+ source_array_path=read_path / raster_path,
815
+ dest_array_path=dest_path / raster_path,
816
+ raster_path=raster_path,
817
+ config=label_config,
818
+ )
819
+ )
820
+
821
+ refresh_consolidated_metadata(dest_path)
822
+
823
+ manifest_data = {
824
+ "format": "spatialdata-codec-recompression/v1",
825
+ "source": source_label,
826
+ "output": str(dest_path),
827
+ "config": resolved_config,
828
+ "images": image_reports,
829
+ "labels": label_reports,
830
+ "workers": worker_count,
831
+ "pyramids": pyramid_reports,
832
+ "htj2k": backend_report(),
833
+ "packages": {
834
+ "imagecodecs": package_version("imagecodecs"),
835
+ "numpy": package_version("numpy"),
836
+ "spatialdata": package_version("spatialdata"),
837
+ "spatialdata-js-util": package_version("spatialdata-js-util"),
838
+ "zarr": package_version("zarr"),
839
+ },
840
+ }
841
+ manifest_path = dest_path.with_suffix(".manifest.json") if manifest else None
842
+ if manifest_path is not None:
843
+ write_json(manifest_path, manifest_data)
844
+ return RecompressedSpatialData(dest_path, manifest_path, manifest_data)
845
+ finally:
846
+ if temp_dir is not None:
847
+ temp_dir.cleanup()
848
+ if pyramid_dir is not None:
849
+ pyramid_dir.cleanup()