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,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()
|