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,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}")
|