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,67 @@
1
+ """Image codecs for browser-oriented SpatialData stores.
2
+
3
+ `names` holds the on-disk codec identifiers, `backends` picks an HTJ2K
4
+ implementation, `encoding` does the chunk-level work, and `zarr_codec` exposes
5
+ all of it to zarr-python as registered codecs.
6
+ """
7
+
8
+ from .backends import (
9
+ BACKEND_IMAGECODECS,
10
+ BACKEND_OPENJPH_WASM,
11
+ backend_report,
12
+ htj2k_available,
13
+ resolve_backend,
14
+ )
15
+ from .chunks import chunk_grid, chunk_slices, pad_chunk
16
+ from .encoding import (
17
+ HTJ2K_DEFAULT_QUALITY_LSB,
18
+ HTJ2K_QUALITY_FLOOR_LSB,
19
+ decode_htj2k_plane,
20
+ decode_image_chunk,
21
+ decode_image_plane,
22
+ dtype_quantum,
23
+ encode_image_chunk,
24
+ encode_image_plane,
25
+ htj2k_encode_options,
26
+ )
27
+ from .names import (
28
+ CODEC_HTJ2K_LEGACY,
29
+ CODEC_HTJ2K_OPENJPH,
30
+ CODEC_JPEG2K,
31
+ HTJ2K_ENCODER,
32
+ SUPPORTED_IMAGE_CODECS,
33
+ CodecName,
34
+ is_htj2k_codec,
35
+ )
36
+ from .zarr_codec import Htj2kCodec, Jpeg2kCodec, LegacyHtj2kCodec, register_codecs
37
+
38
+ __all__ = [
39
+ "BACKEND_IMAGECODECS",
40
+ "BACKEND_OPENJPH_WASM",
41
+ "CODEC_HTJ2K_LEGACY",
42
+ "CODEC_HTJ2K_OPENJPH",
43
+ "CODEC_JPEG2K",
44
+ "HTJ2K_DEFAULT_QUALITY_LSB",
45
+ "HTJ2K_ENCODER",
46
+ "HTJ2K_QUALITY_FLOOR_LSB",
47
+ "SUPPORTED_IMAGE_CODECS",
48
+ "Htj2kCodec",
49
+ "Jpeg2kCodec",
50
+ "LegacyHtj2kCodec",
51
+ "CodecName",
52
+ "backend_report",
53
+ "chunk_grid",
54
+ "chunk_slices",
55
+ "decode_htj2k_plane",
56
+ "decode_image_chunk",
57
+ "decode_image_plane",
58
+ "dtype_quantum",
59
+ "encode_image_chunk",
60
+ "encode_image_plane",
61
+ "htj2k_available",
62
+ "htj2k_encode_options",
63
+ "is_htj2k_codec",
64
+ "pad_chunk",
65
+ "register_codecs",
66
+ "resolve_backend",
67
+ ]
@@ -0,0 +1,250 @@
1
+ """HTJ2K backend selection.
2
+
3
+ Two backends can produce and consume the HTJ2K codestreams this package writes:
4
+
5
+ ``imagecodecs``
6
+ Native OpenJPH via `imagecodecs.htj2k_encode/decode`. Pure `pip install` —
7
+ no Node.js — so it is what makes the reader shim usable from a plain
8
+ SpatialData environment.
9
+
10
+ ``openjph-wasm``
11
+ The vendored WASM build driven through a pool of Node.js worker processes.
12
+ This is the same WASM the JS reader uses, so it is the reference for
13
+ encode/decode agreement with the browser.
14
+
15
+ Historically only the WASM backend was trusted, because the WASM decoder this
16
+ repo previously depended on silently dropped every component but the first (see
17
+ `docs/multi-component-codec-findings.md`). That defect is specific to that build,
18
+ not to OpenJPH — but "a build of OpenJPH" is not on its own evidence of
19
+ correctness. So `imagecodecs` is not trusted on reputation: it is admitted only
20
+ if it decodes a committed multi-component codestream to the exact expected
21
+ samples. The probe runs once per process and the backend is rejected on any
22
+ mismatch.
23
+
24
+ The fixture's expected samples are generated in numpy rather than by a decoder,
25
+ so the ground truth is codec-independent. Which backend *encoded* the codestream
26
+ is recorded alongside it and reported by `backend_report()` — probing a backend
27
+ against a codestream it produced itself only shows self-consistency, so that
28
+ provenance is part of reading the result. See `scripts/build_htj2k_probe.py`.
29
+
30
+ Set ``SPATIALDATA_JS_UTIL_HTJ2K_BACKEND=imagecodecs|openjph-wasm`` to pin one
31
+ backend explicitly (the probe still gates ``imagecodecs``).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ from functools import lru_cache
39
+ from pathlib import Path
40
+ from typing import Any, Protocol
41
+
42
+ import numpy as np
43
+
44
+ BACKEND_IMAGECODECS = "imagecodecs"
45
+ BACKEND_OPENJPH_WASM = "openjph-wasm"
46
+
47
+ _BACKEND_ENV_VAR = "SPATIALDATA_JS_UTIL_HTJ2K_BACKEND"
48
+ _PROBE_DIR = Path(__file__).resolve().parent / "probe"
49
+ _PROBE_CODESTREAM = _PROBE_DIR / "multicomponent.j2c"
50
+ _PROBE_EXPECTED = _PROBE_DIR / "multicomponent.npy"
51
+ _PROBE_PROVENANCE = _PROBE_DIR / "multicomponent.json"
52
+
53
+
54
+ class Htj2kBackend(Protocol):
55
+ """Encode/decode HTJ2K codestreams of planar ``(components, y, x)`` volumes."""
56
+
57
+ name: str
58
+
59
+ def available(self) -> bool: ...
60
+
61
+ def encode(self, volume: np.ndarray, *, reversible: bool, quality: float) -> bytes: ...
62
+
63
+ def decode(self, codestream: bytes | bytearray) -> np.ndarray: ...
64
+
65
+
66
+ def _as_planar_3d(array: np.ndarray) -> np.ndarray:
67
+ """Normalise a decoded result to ``(components, y, x)``."""
68
+ if array.ndim == 2:
69
+ return array.reshape(1, *array.shape)
70
+ if array.ndim == 3:
71
+ return array
72
+ raise ValueError(f"Expected a 2D or 3D HTJ2K decode result, got shape {array.shape}")
73
+
74
+
75
+ class ImagecodecsHtj2kBackend:
76
+ """Native OpenJPH through `imagecodecs`; no Node.js required."""
77
+
78
+ name = BACKEND_IMAGECODECS
79
+
80
+ def available(self) -> bool:
81
+ try:
82
+ import imagecodecs
83
+ except ImportError:
84
+ return False
85
+ return bool(getattr(imagecodecs, "HTJ2K", None) and imagecodecs.HTJ2K.available)
86
+
87
+ def encode(self, volume: np.ndarray, *, reversible: bool, quality: float) -> bytes:
88
+ import imagecodecs
89
+
90
+ array = np.ascontiguousarray(np.asarray(volume))
91
+ planar = array.ndim == 3 and array.shape[0] > 1
92
+ if array.ndim == 3 and array.shape[0] == 1:
93
+ array = array.reshape(array.shape[1], array.shape[2])
94
+ if reversible:
95
+ # `level` is the OpenJPH quantization step; omitting it selects the
96
+ # reversible (5/3) transform.
97
+ return bytes(imagecodecs.htj2k_encode(array, reversible=True, planar=planar, rgb=False))
98
+ return bytes(
99
+ imagecodecs.htj2k_encode(
100
+ array, level=float(quality), reversible=False, planar=planar, rgb=False
101
+ )
102
+ )
103
+
104
+ def decode(self, codestream: bytes | bytearray) -> np.ndarray:
105
+ import imagecodecs
106
+
107
+ return _as_planar_3d(np.asarray(imagecodecs.htj2k_decode(bytes(codestream), planar=True)))
108
+
109
+
110
+ class OpenJphWasmHtj2kBackend:
111
+ """Vendored openjph-wasm driven by Node.js worker processes."""
112
+
113
+ name = BACKEND_OPENJPH_WASM
114
+
115
+ def available(self) -> bool:
116
+ from .htj2k_wasm import wasm_encode_available
117
+
118
+ return wasm_encode_available()
119
+
120
+ def encode(self, volume: np.ndarray, *, reversible: bool, quality: float) -> bytes:
121
+ from .htj2k_wasm import encode_htj2k_wasm
122
+
123
+ return encode_htj2k_wasm(volume, reversible=reversible, quality=quality)
124
+
125
+ def decode(self, codestream: bytes | bytearray) -> np.ndarray:
126
+ from .htj2k_wasm import decode_htj2k_wasm
127
+
128
+ return _as_planar_3d(decode_htj2k_wasm(codestream))
129
+
130
+
131
+ _BACKENDS: dict[str, Htj2kBackend] = {
132
+ BACKEND_IMAGECODECS: ImagecodecsHtj2kBackend(),
133
+ BACKEND_OPENJPH_WASM: OpenJphWasmHtj2kBackend(),
134
+ }
135
+
136
+ # `imagecodecs` first so that a plain `pip install` can both read and write
137
+ # without Node.js. The probe below is what makes that safe.
138
+ _PREFERENCE = (BACKEND_IMAGECODECS, BACKEND_OPENJPH_WASM)
139
+
140
+
141
+ def probe_fixture() -> tuple[bytes, np.ndarray]:
142
+ """Return the committed multi-component codestream and its expected samples."""
143
+ if not _PROBE_CODESTREAM.is_file() or not _PROBE_EXPECTED.is_file():
144
+ raise FileNotFoundError(
145
+ f"HTJ2K probe fixture missing under {_PROBE_DIR}. "
146
+ "Regenerate it with scripts/build_htj2k_probe.py."
147
+ )
148
+ return _PROBE_CODESTREAM.read_bytes(), np.load(_PROBE_EXPECTED)
149
+
150
+
151
+ def probe_encoder() -> str | None:
152
+ """Which backend encoded the committed fixture, or `None` if unrecorded.
153
+
154
+ A backend probed against a codestream it produced itself has only shown that
155
+ it is self-consistent, so this is needed to read a probe result correctly.
156
+ """
157
+ if not _PROBE_PROVENANCE.is_file():
158
+ return None
159
+ try:
160
+ encoder = json.loads(_PROBE_PROVENANCE.read_text()).get("encoder")
161
+ except (OSError, ValueError):
162
+ return None
163
+ return encoder if isinstance(encoder, str) else None
164
+
165
+
166
+ def available_backends() -> list[Htj2kBackend]:
167
+ """Every installed backend, in preference order.
168
+
169
+ Exposed for the probe builder, which has to encode with one backend and
170
+ check the result against the others rather than take the single selected one.
171
+ """
172
+ return [_BACKENDS[name] for name in _PREFERENCE if _BACKENDS[name].available()]
173
+
174
+
175
+ @lru_cache(maxsize=None)
176
+ def backend_passes_probe(name: str) -> bool:
177
+ """Whether *name* decodes the committed multi-component fixture exactly.
178
+
179
+ A backend that raises, returns the wrong shape, or returns wrong samples
180
+ fails. Multi-component correctness cannot be assumed from the library name:
181
+ the WASM build previously used here decoded every component as component 0.
182
+ """
183
+ backend = _BACKENDS.get(name)
184
+ if backend is None or not backend.available():
185
+ return False
186
+ try:
187
+ codestream, expected = probe_fixture()
188
+ decoded = backend.decode(codestream)
189
+ except Exception:
190
+ return False
191
+ return decoded.shape == expected.shape and np.array_equal(decoded, expected)
192
+
193
+
194
+ @lru_cache(maxsize=1)
195
+ def resolve_backend() -> Htj2kBackend | None:
196
+ """Return the HTJ2K backend to use, or ``None`` if none is usable."""
197
+ requested = os.environ.get(_BACKEND_ENV_VAR)
198
+ candidates = (requested,) if requested else _PREFERENCE
199
+ if requested and requested not in _BACKENDS:
200
+ raise ValueError(
201
+ f"Unknown {_BACKEND_ENV_VAR}={requested!r}; expected one of {sorted(_BACKENDS)}"
202
+ )
203
+ for name in candidates:
204
+ # The WASM backend is the reference the probe fixture was produced with,
205
+ # so it is trusted on availability; every other backend must prove it.
206
+ if name == BACKEND_OPENJPH_WASM:
207
+ if _BACKENDS[name].available():
208
+ return _BACKENDS[name]
209
+ elif backend_passes_probe(name):
210
+ return _BACKENDS[name]
211
+ return None
212
+
213
+
214
+ def reset_backend_cache() -> None:
215
+ """Clear memoized backend selection (used by tests that flip the env var)."""
216
+ resolve_backend.cache_clear()
217
+ backend_passes_probe.cache_clear()
218
+
219
+
220
+ def require_backend() -> Htj2kBackend:
221
+ backend = resolve_backend()
222
+ if backend is None:
223
+ raise RuntimeError(
224
+ "No usable HTJ2K backend. Install `imagecodecs` with HTJ2K support "
225
+ "(pip install 'spatialdata-js-util'), or put Node.js on PATH to use the "
226
+ "vendored openjph-wasm backend."
227
+ )
228
+ return backend
229
+
230
+
231
+ def htj2k_available() -> bool:
232
+ return resolve_backend() is not None
233
+
234
+
235
+ def backend_report() -> dict[str, Any]:
236
+ """Describe backend availability — used by manifests and `codecs info`."""
237
+ selected = resolve_backend()
238
+ return {
239
+ "selected": selected.name if selected else None,
240
+ # Reading a probe result needs to know who produced the codestream: a
241
+ # backend that encoded it has only proved self-consistency by decoding it.
242
+ "probe_encoder": probe_encoder(),
243
+ "backends": {
244
+ name: {
245
+ "available": backend.available(),
246
+ "passes_multicomponent_probe": backend_passes_probe(name),
247
+ }
248
+ for name, backend in _BACKENDS.items()
249
+ },
250
+ }
@@ -0,0 +1,40 @@
1
+ """Chunk-grid arithmetic shared by the recompressor and its tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def chunk_grid(shape: tuple[int, ...], chunks: tuple[int, ...]) -> list[tuple[int, ...]]:
9
+ if len(shape) != len(chunks):
10
+ raise ValueError(
11
+ f"shape and chunks must have the same length (got {len(shape)} and {len(chunks)})"
12
+ )
13
+ ranges = [range((size + chunk - 1) // chunk) for size, chunk in zip(shape, chunks)]
14
+ out: list[tuple[int, ...]] = [()]
15
+ for values in ranges:
16
+ out = [(*prefix, value) for prefix in out for value in values]
17
+ return out
18
+
19
+
20
+ def chunk_slices(
21
+ shape: tuple[int, ...], chunks: tuple[int, ...], coords: tuple[int, ...]
22
+ ) -> tuple[slice, ...]:
23
+ if not (len(shape) == len(chunks) == len(coords)):
24
+ raise ValueError(
25
+ "shape, chunks, and coords must have the same length "
26
+ f"(got {len(shape)}, {len(chunks)}, and {len(coords)})"
27
+ )
28
+ slices = []
29
+ for coord, chunk, size in zip(coords, chunks, shape):
30
+ start = coord * chunk
31
+ slices.append(slice(start, min(start + chunk, size)))
32
+ return tuple(slices)
33
+
34
+
35
+ def pad_chunk(chunk: np.ndarray, chunks: tuple[int, ...]) -> np.ndarray:
36
+ if chunk.shape == chunks:
37
+ return chunk
38
+ padded = np.zeros(chunks, dtype=chunk.dtype)
39
+ padded[tuple(slice(0, size) for size in chunk.shape)] = chunk
40
+ return padded
@@ -0,0 +1,154 @@
1
+ """Encode and decode image chunks for the codecs this package supports.
2
+
3
+ JPEG 2000 goes through `imagecodecs`. HTJ2K goes through whichever backend
4
+ `backends.resolve_backend()` selects. Both directions are expressed in terms of
5
+ planar ``(components, y, x)`` volumes; 2D planes are the single-component case.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from .backends import require_backend
15
+ from .names import CODEC_HTJ2K_OPENJPH, CODEC_JPEG2K, is_htj2k_codec
16
+
17
+
18
+ # A quantization step below one input LSB cannot buy fidelity the input does not
19
+ # have: the irreversible 9/7 path returns a bit-identical image while spending
20
+ # more bytes than the reversible 5/3 path. See `images.HTJ2K_PRESETS`.
21
+ HTJ2K_QUALITY_FLOOR_LSB = 1.0
22
+
23
+ # Fallback for `reversible=False` with no step given, in LSB. Matches the
24
+ # `balanced` preset.
25
+ HTJ2K_DEFAULT_QUALITY_LSB = 2.0
26
+
27
+
28
+ def dtype_quantum(dtype: np.dtype) -> float:
29
+ """Return one LSB of *dtype* as a fraction of its full dynamic range.
30
+
31
+ OpenJPH's quantization step is normalised to the dtype's full range, so this
32
+ is the unit that makes a step comparable across bit depths.
33
+
34
+ Only integer dtypes have an LSB to be relative to. That reflects what this
35
+ codec path writes today, not a limit of HTJ2K — if float support is added,
36
+ give those dtypes a quantum here rather than reading the error as permanent.
37
+ """
38
+ resolved = np.dtype(dtype)
39
+ if resolved.kind not in ("i", "u"):
40
+ raise TypeError(
41
+ f"HTJ2K quantization steps are relative to an integer dtype's full range, "
42
+ f"and {resolved} has no LSB to scale by. Images are currently written as "
43
+ f"<=16-bit integers, so no step is derived for this dtype yet — pass an "
44
+ f"explicit `quality` (an absolute step) or `reversible=True` to encode it."
45
+ )
46
+ info = np.iinfo(resolved)
47
+ return 1.0 / (int(info.max) - int(info.min) + 1)
48
+
49
+
50
+ def htj2k_encode_options(
51
+ encode_options: dict[str, Any], *, dtype: np.dtype | None = None
52
+ ) -> tuple[bool, float]:
53
+ """Map encode options to the OpenJPH ``(reversible, quality)`` pair.
54
+
55
+ ``quality`` is the OpenJPH quantization step: lower means higher fidelity and
56
+ a larger codestream. It is *not* a JPEG-style 0–100 quality.
57
+
58
+ A caller-supplied ``quality``/``level`` is used as given. Only the fallback
59
+ is derived from *dtype*, so that the default scales with bit depth instead of
60
+ being a bit-depth-blind constant; deriving it needs an integer dtype.
61
+ """
62
+ reversible = bool(encode_options.get("reversible", True))
63
+ if reversible:
64
+ return True, 0.0
65
+ quality = encode_options.get("quality", encode_options.get("level"))
66
+ if quality is None:
67
+ # Derived only when the caller gave no step. An explicit `quality` is an
68
+ # absolute value, so it must not depend on the dtype — deriving eagerly
69
+ # would reject dtypes that can be encoded perfectly well by naming a step.
70
+ quality = HTJ2K_DEFAULT_QUALITY_LSB * dtype_quantum(dtype) if dtype is not None else 0.0002
71
+ return False, float(quality)
72
+
73
+
74
+ def _encode_htj2k(volume: np.ndarray, encode_options: dict[str, Any] | None = None) -> bytes:
75
+ reversible, quality = htj2k_encode_options(encode_options or {}, dtype=volume.dtype)
76
+ return require_backend().encode(volume, reversible=reversible, quality=quality)
77
+
78
+
79
+ def _htj2k_components(array: np.ndarray) -> int:
80
+ return int(np.prod(array.shape[:-2])) if array.ndim > 2 else 1
81
+
82
+
83
+ def decode_htj2k_plane(encoded: bytes | bytearray) -> np.ndarray:
84
+ """Decode an HTJ2K codestream.
85
+
86
+ Returns a 2D ``(y, x)`` array for single-component codestreams, otherwise a
87
+ ``(components, y, x)`` array.
88
+ """
89
+ array = require_backend().decode(encoded)
90
+ return array[0] if array.shape[0] == 1 else array
91
+
92
+
93
+ def decode_image_plane(encoded: bytes | bytearray, codec: str) -> np.ndarray:
94
+ if codec == CODEC_JPEG2K:
95
+ import imagecodecs
96
+
97
+ return imagecodecs.jpeg2k_decode(encoded)
98
+ if is_htj2k_codec(codec):
99
+ return decode_htj2k_plane(encoded)
100
+ raise ValueError(f"Unsupported image codec: {codec}")
101
+
102
+
103
+ def encode_image_plane(
104
+ plane: np.ndarray, codec: str, encode_options: dict[str, Any]
105
+ ) -> bytes | bytearray:
106
+ array = np.asarray(plane)
107
+ if codec == CODEC_JPEG2K:
108
+ import imagecodecs
109
+
110
+ return imagecodecs.jpeg2k_encode(array, **encode_options)
111
+ if codec == CODEC_HTJ2K_OPENJPH:
112
+ return _encode_htj2k(array, encode_options)
113
+ raise ValueError(f"Unsupported image codec: {codec}")
114
+
115
+
116
+ def encode_image_chunk(
117
+ volume: np.ndarray, codec: str, encode_options: dict[str, Any]
118
+ ) -> bytes | bytearray:
119
+ """Encode one or more planar components to a single codestream.
120
+
121
+ ``volume`` is ``(components, y, x)`` (or 2D for a single component). HTJ2K
122
+ encodes the planes as codestream components (e.g. z-planes of a volumetric
123
+ chunk); multi-component JPEG2K chunks are not produced by this writer.
124
+ """
125
+ array = np.ascontiguousarray(np.asarray(volume))
126
+ components = _htj2k_components(array)
127
+ if codec == CODEC_HTJ2K_OPENJPH:
128
+ return _encode_htj2k(array, encode_options)
129
+ if codec == CODEC_JPEG2K:
130
+ import imagecodecs
131
+
132
+ if components == 1:
133
+ plane = array.reshape(array.shape[-2], array.shape[-1])
134
+ return imagecodecs.jpeg2k_encode(plane, **encode_options)
135
+ raise NotImplementedError(
136
+ "Multi-component JPEG2K chunks are not supported; use HTJ2K (openjph)."
137
+ )
138
+ raise ValueError(f"Unsupported image codec: {codec}")
139
+
140
+
141
+ def decode_image_chunk(
142
+ encoded: bytes | bytearray, codec: str, *, components: int, height: int, width: int
143
+ ) -> np.ndarray:
144
+ """Decode a chunk codestream to a ``(components, y, x)`` array."""
145
+ if is_htj2k_codec(codec):
146
+ return require_backend().decode(encoded).reshape(components, height, width)
147
+ if codec == CODEC_JPEG2K:
148
+ import imagecodecs
149
+
150
+ decoded = np.asarray(imagecodecs.jpeg2k_decode(encoded))
151
+ if decoded.ndim == 2:
152
+ return decoded.reshape(1, height, width)
153
+ return np.moveaxis(decoded, -1, 0).reshape(components, height, width)
154
+ raise ValueError(f"Unsupported image codec: {codec}")