micro-reader 0.0.1__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.
@@ -0,0 +1,409 @@
1
+ """Imaris (.ims): HDF5 with a fixed layout (``[ims]`` extra, h5py).
2
+
3
+ /DataSet/ResolutionLevel r/TimePoint t/Channel c/Data 3D (Z, Y, X), chunked
4
+ /DataSet/ResolutionLevel r/TimePoint t/Channel c .attrs ImageSizeZ/Y/X
5
+
6
+ Two things differ from reading the datasets through h5py:
7
+
8
+ - **Padding is cropped.** Imaris pads every ``Data`` dataset to whole HDF5
9
+ chunks (a 424 x 424 image stored as 512 x 512, zeros beyond); the real size
10
+ is in the ``ImageSize`` attributes. Images have the real size, as
11
+ Bio-Formats and Imaris show it.
12
+ - **Chunks are read and decoded here, in parallel.** h5py runs every HDF5
13
+ call -- decompression included -- under one global lock, so threads do not
14
+ speed it up. h5py only tells us where each chunk is (``chunk_iter``); the
15
+ chunks are then read with per-thread file handles and decompressed on the
16
+ shared decode pool. Filters handled: deflate (gzip), shuffle, Fletcher-32,
17
+ LZ4 (ImarisWriter), zstd. A dataset with any other filter (szip, ...) or no chunking is read
18
+ through h5py instead: correct, just not parallel.
19
+
20
+ One image, full resolution, axes ``tczyx``; every ``ResolutionLevel r`` is in
21
+ ``image.resolutions``, and an image of its own with ``resolutions="all"``
22
+ (index ``{"scene": 0, "resolution": r}``).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import builtins
27
+ import os
28
+ import threading
29
+ import zlib
30
+
31
+ import numpy as np
32
+
33
+ from .. import _codecs, _memory, _pool
34
+ from .._base import File, Image
35
+ from .._errors import CorruptFileError, MicroReaderError, UnsupportedFormatError
36
+ from .._io import DecodeCache, ThreadHandles
37
+ from .._metadata import Channel, Metadata, Quantity, color_hex, quantity
38
+
39
+ NAME = "ims"
40
+ HDF5_MAGIC = b"\x89HDF\r\n\x1a\n"
41
+ DECODE_CACHE_BYTES = 64 << 20
42
+ FILTER_DEFLATE, FILTER_SHUFFLE, FILTER_FLETCHER32 = 1, 2, 3
43
+ FILTER_LZ4 = 32004 # HDF Group registered filter; ImarisWriter writes it
44
+ FILTER_ZSTD = 32015 # registered zstd filter: one zstd frame per chunk
45
+ SUPPORTED_FILTERS = {FILTER_DEFLATE, FILTER_SHUFFLE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_ZSTD}
46
+ #: registered HDF5 filters h5py cannot apply without plugins (named in errors)
47
+ FILTER_NAMES = {4: "szip", 307: "bzip2", 32001: "Blosc", 32004: "LZ4", 32008: "bitshuffle",
48
+ 32013: "zfp", 32015: "zstd", 32017: "SZ", 32026: "Blosc2"}
49
+
50
+
51
+ def _lz4_unframe(data: bytes, path: str) -> bytes:
52
+ """HDF5's LZ4 filter (H5Zlz4.c): big-endian original size (8 bytes) and
53
+ block size (4), then per block its compressed size (4) and the LZ4
54
+ block -- stored as is when the compressed size equals the block size."""
55
+ total = int.from_bytes(data[:8], "big")
56
+ block = min(int.from_bytes(data[8:12], "big"), total) or total
57
+ out = np.empty(total, np.uint8)
58
+ pos, done = 12, 0
59
+ while done < total:
60
+ size = min(block, total - done)
61
+ stored = int.from_bytes(data[pos:pos + 4], "big")
62
+ pos += 4
63
+ if pos + stored > len(data):
64
+ raise CorruptFileError(f"{path!r}: LZ4 chunk ends early")
65
+ if stored == size:
66
+ out[done:done + size] = np.frombuffer(data, np.uint8, size, pos)
67
+ else:
68
+ _codecs.lz4_block_decompress_into(memoryview(data)[pos:pos + stored],
69
+ out[done:done + size])
70
+ pos += stored
71
+ done += size
72
+ return out.tobytes()
73
+
74
+
75
+ def sniff(head: bytes, path: str) -> bool:
76
+ # the HDF5 superblock sits at 0, or after a user block of 512 * 2**n bytes
77
+ return head[:8] == HDF5_MAGIC or head[512:520] == HDF5_MAGIC
78
+
79
+
80
+ def _attr_str(attrs, key):
81
+ """Imaris attributes are arrays of 1-byte strings."""
82
+ if key not in attrs:
83
+ return None
84
+ value = attrs[key]
85
+ if isinstance(value, np.ndarray):
86
+ return b"".join(v if isinstance(v, bytes) else str(v).encode() for v in value).decode()
87
+ return value.decode() if isinstance(value, bytes) else str(value)
88
+
89
+
90
+ def _image_size(ds) -> tuple:
91
+ """Real (Z, Y, X) of a ``Data`` dataset: its Channel group's ImageSize
92
+ attributes, else the dataset shape."""
93
+ sizes = []
94
+ for axis, stored in zip("ZYX", ds.shape):
95
+ try:
96
+ size = int(_attr_str(ds.parent.attrs, f"ImageSize{axis}"))
97
+ except (TypeError, ValueError):
98
+ size = stored
99
+ sizes.append(size if 0 < size <= stored else stored)
100
+ return tuple(sizes)
101
+
102
+
103
+ def _numbered(group, prefix) -> list:
104
+ """Indices of ``'{prefix} {n}'`` members, which must be 0..n-1."""
105
+ found = sorted(int(k[len(prefix) + 1:]) for k in group
106
+ if k.startswith(prefix + " ") and k[len(prefix) + 1:].isdigit())
107
+ if found != list(range(len(found))) or not found:
108
+ raise UnsupportedFormatError(f"Imaris file with {prefix} groups {found}")
109
+ return found
110
+
111
+
112
+ class _Layout:
113
+ """How one ``Data`` dataset is stored: where each chunk is and how to
114
+ decode it; or ``native = False`` to read it through h5py."""
115
+
116
+ def __init__(self, ds):
117
+ self.path = ds.file.filename
118
+ self.dtype = ds.dtype # file byte order
119
+ self.fill = ds.fillvalue
120
+ self.chunks = ds.chunks
121
+ dcpl = ds.id.get_create_plist()
122
+ self.filters = [dcpl.get_filter(i)[0] for i in range(dcpl.get_nfilters())]
123
+ self.native = bool(self.chunks) and set(self.filters) <= SUPPORTED_FILTERS
124
+ self.chunk_nbytes = int(np.prod(self.chunks)) * self.dtype.itemsize if self.chunks else 0
125
+ self.where: dict = {} # chunk origin -> (byte offset, size, filter mask)
126
+ if self.native:
127
+ ds.id.chunk_iter(lambda info: self.where.__setitem__(
128
+ tuple(info.chunk_offset), (info.byte_offset, info.size, info.filter_mask)))
129
+ shift = self._userblock_shift(ds)
130
+ if shift is None:
131
+ self.native, self.where = False, {} # positions unknown: read through h5py
132
+ elif shift:
133
+ self.where = {k: (o + shift, n, m) for k, (o, n, m) in self.where.items()}
134
+
135
+ def _userblock_shift(self, ds):
136
+ """Bytes to add to the chunk offsets HDF5 reports. Before HDF5
137
+ 1.14.6 (seen with 1.14.2) they leave out a user block at the start of
138
+ the file; later versions include it. One chunk's stored bytes
139
+ (read_direct_chunk) are compared with the file at both positions;
140
+ None when neither matches."""
141
+ userblock = ds.file.id.get_create_plist().get_userblock()
142
+ if not userblock or not self.where:
143
+ return 0
144
+ origin, (offset, size, _) = next(iter(self.where.items()))
145
+ _, stored = ds.id.read_direct_chunk(origin)
146
+ stored = bytes(stored)
147
+ with builtins.open(self.path, "rb") as fh:
148
+ for shift in (0, userblock):
149
+ fh.seek(offset + shift)
150
+ if fh.read(len(stored)) == stored:
151
+ return shift
152
+ return None
153
+
154
+ def decode(self, data: bytes, mask: int) -> np.ndarray:
155
+ """Undo the filter pipeline (in reverse; bit i of *mask* set means
156
+ filter i was skipped for this chunk)."""
157
+ for i in reversed(range(len(self.filters))):
158
+ if mask & (1 << i):
159
+ continue
160
+ fid = self.filters[i]
161
+ if fid == FILTER_DEFLATE:
162
+ data = zlib.decompress(data, 15, self.chunk_nbytes)
163
+ elif fid == FILTER_LZ4:
164
+ data = _lz4_unframe(data, self.path)
165
+ elif fid == FILTER_ZSTD:
166
+ out = np.empty(self.chunk_nbytes, np.uint8)
167
+ _codecs.zstd_decompress_into(data, out)
168
+ data = out.tobytes()
169
+ elif fid == FILTER_SHUFFLE:
170
+ item = self.dtype.itemsize
171
+ if item > 1:
172
+ b = np.frombuffer(data, np.uint8)
173
+ n = len(b) // item
174
+ data = b[:n * item].reshape(item, n).T.tobytes() + bytes(b[n * item:])
175
+ elif fid == FILTER_FLETCHER32:
176
+ data = data[:-4] # checksum; not verified
177
+ if len(data) != self.chunk_nbytes:
178
+ raise CorruptFileError(f"chunk decodes to {len(data)} bytes, "
179
+ f"expected {self.chunk_nbytes}")
180
+ return np.frombuffer(data, self.dtype).reshape(self.chunks)
181
+
182
+
183
+ class _ImsImage(Image):
184
+ """One resolution level: all time points and channels, axes 'tczyx'."""
185
+
186
+ def __init__(self, h5, path: str, level: int = 0, key: dict = None):
187
+ base = f"DataSet/ResolutionLevel {level}"
188
+ if base not in h5:
189
+ raise UnsupportedFormatError(f"{path!r}: HDF5 file without Imaris {base!r}")
190
+ group = h5[base]
191
+ times = _numbered(group, "TimePoint")
192
+ channels = _numbered(group[f"TimePoint {times[0]}"], "Channel")
193
+ try:
194
+ self._datasets = [[group[f"TimePoint {t}/Channel {c}/Data"] for c in channels]
195
+ for t in times]
196
+ except KeyError as exc:
197
+ raise UnsupportedFormatError(f"{path!r}: incomplete Imaris data ({exc})") from exc
198
+ all_ds = [ds for row in self._datasets for ds in row]
199
+ sizes = {_image_size(ds) for ds in all_ds}
200
+ dtypes = {ds.dtype.newbyteorder("=") for ds in all_ds}
201
+ if len(sizes) != 1 or len(dtypes) != 1:
202
+ raise UnsupportedFormatError(
203
+ f"{path!r}: channels / time points differ in size {sizes} or type {dtypes}")
204
+ self._path = path
205
+ self._h5 = h5
206
+ self._channel_ids = channels
207
+ self._lock = threading.Lock() # h5py objects: one caller at a time
208
+ self._layouts: dict = {}
209
+ self._handles = ThreadHandles(path)
210
+ #: decoded chunks kept for reuse (``decode_cache.max_bytes`` to resize)
211
+ self.decode_cache = DecodeCache(DECODE_CACHE_BYTES)
212
+ self._closed = False
213
+ chunks = all_ds[0].chunks
214
+ super().__init__(level, "tczyx", (len(times), len(channels), *sizes.pop()),
215
+ dtypes.pop(), name=os.path.basename(path),
216
+ read_unit=(1, 1, *chunks) if chunks else None,
217
+ index=key if key is not None else {"scene": 0})
218
+
219
+ def _layout(self, t: int, c: int) -> _Layout:
220
+ layout = self._layouts.get((t, c))
221
+ if layout is None:
222
+ with self._lock:
223
+ layout = self._layouts.get((t, c))
224
+ if layout is None:
225
+ layout = self._layouts[(t, c)] = _Layout(self._datasets[t][c])
226
+ return layout
227
+
228
+ def _read_block(self, block):
229
+ if self._closed:
230
+ raise ValueError("this Imaris image has been closed: its file was closed "
231
+ "(File.close() or the end of a with block)")
232
+ ts, cs, zs, ys, xs = block
233
+ out = np.empty(tuple(s.stop - s.start for s in block), dtype=self.dtype)
234
+ read, jobs = 0, []
235
+ for t in range(ts.start, ts.stop):
236
+ for c in range(cs.start, cs.stop):
237
+ dest = out[t - ts.start, c - cs.start]
238
+ layout = self._layout(t, c)
239
+ if not layout.native: # other filters / not chunked: h5py
240
+ with self._lock:
241
+ try:
242
+ dest[...] = self._datasets[t][c][zs, ys, xs]
243
+ except OSError as exc:
244
+ raise self._filter_error(layout, exc) from exc
245
+ read += dest.size
246
+ continue
247
+ self._plan(t, c, layout, zs, ys, xs, dest, jobs)
248
+ return out, read + _pool.run(self._tasks(jobs))
249
+
250
+ def _filter_error(self, layout, exc: Exception) -> Exception:
251
+ """h5py's read failed: name the HDF5 filters it could not apply."""
252
+ missing = [f for f in layout.filters if f not in SUPPORTED_FILTERS and f != 32000]
253
+ if not missing:
254
+ return CorruptFileError(f"{self._path!r}: {exc}")
255
+ names = ", ".join(f"{FILTER_NAMES.get(f, 'unknown')} ({f})" for f in missing)
256
+ return UnsupportedFormatError(
257
+ f"{self._path!r}: HDF5 filter {names} is not an Imaris one; h5py applies it "
258
+ f"with the hdf5plugin package (pip install hdf5plugin). h5py said: {exc}")
259
+
260
+ def _plan(self, t, c, layout, zs, ys, xs, dest, jobs) -> None:
261
+ """Every chunk the rectangle touches: missing ones (never written)
262
+ take the fill value here, the others become jobs."""
263
+ cz, cy, cx = layout.chunks
264
+ for oz in range(zs.start // cz * cz, zs.stop, cz):
265
+ for oy in range(ys.start // cy * cy, ys.stop, cy):
266
+ for ox in range(xs.start // cx * cx, xs.stop, cx):
267
+ z0, z1 = max(zs.start, oz), min(zs.stop, oz + cz)
268
+ y0, y1 = max(ys.start, oy), min(ys.stop, oy + cy)
269
+ x0, x1 = max(xs.start, ox), min(xs.stop, ox + cx)
270
+ dst = dest[z0 - zs.start:z1 - zs.start, y0 - ys.start:y1 - ys.start,
271
+ x0 - xs.start:x1 - xs.start]
272
+ part = (slice(z0 - oz, z1 - oz), slice(y0 - oy, y1 - oy),
273
+ slice(x0 - ox, x1 - ox))
274
+ where = layout.where.get((oz, oy, ox))
275
+ if where is None:
276
+ dst[...] = layout.fill
277
+ else:
278
+ jobs.append(((t, c, oz, oy, ox), layout, where, dst, part))
279
+
280
+ def _tasks(self, jobs):
281
+ for key, layout, where, dst, part in jobs:
282
+ chunk = self.decode_cache.get(key)
283
+ if chunk is not None:
284
+ dst[...] = chunk[part]
285
+ continue
286
+ yield lambda job=(key, layout, where, dst, part): self._decode_into(*job)
287
+
288
+ def _decode_into(self, key, layout, where, dst, part) -> int:
289
+ """Read, decode and copy one chunk (on a pool thread)."""
290
+ offset, size, mask = where
291
+ with _memory.reserve(layout.chunk_nbytes, f"{self._path!r}: an HDF5 chunk {layout.chunks}"):
292
+ chunk = layout.decode(self._handles.read(offset, size), mask)
293
+ dst[...] = chunk[part]
294
+ self.decode_cache.put(key, chunk)
295
+ return chunk.size
296
+
297
+ def close(self) -> None:
298
+ self._closed = True
299
+ self._handles.close()
300
+ self.decode_cache.clear()
301
+
302
+
303
+ def _read_metadata(self):
304
+ with self._lock:
305
+ return _metadata(self._h5, self.shape, self._channel_ids)
306
+
307
+
308
+ def _metadata(h5, shape: tuple, channel_ids: list):
309
+ """``DataSetInfo``: the volume's extent (``ExtMin`` / ``ExtMax`` 0-2 = x,
310
+ y, z, edges of the outer pixels, in ``Unit``) over the image size gives
311
+ the pixel sizes and the position of the first pixel; ``TimeInfo`` the
312
+ time points; ``Channel c`` name, colour (``r g b`` in 0-1) and
313
+ wavelengths (nm)."""
314
+ if "DataSetInfo" not in h5:
315
+ return None
316
+ info = h5["DataSetInfo"]
317
+ image = info["Image"].attrs if "Image" in info else {}
318
+ unit = _attr_str(image, "Unit") or "um"
319
+ sizes, position = {}, {}
320
+ nz, ny, nx = shape[2:]
321
+ for k, (axis, n) in enumerate((("x", nx), ("y", ny), ("z", nz))):
322
+ lo, hi = (quantity(_attr_str(image, f"Ext{e}{k}"), unit, positive=False)
323
+ for e in ("Min", "Max"))
324
+ if lo is None or hi is None:
325
+ continue
326
+ size = quantity((hi.value - lo.value) / n, lo.unit)
327
+ if size:
328
+ sizes[axis] = size
329
+ if axis != "z":
330
+ position[axis] = Quantity(lo.value + size.value / 2, lo.unit)
331
+ times = _time_points(info["TimeInfo"].attrs) if "TimeInfo" in info else []
332
+ if len(times) > 1 and shape[0] > 1:
333
+ step = quantity((times[-1] - times[0]).total_seconds() / (len(times) - 1), "second")
334
+ if step:
335
+ sizes["t"] = step
336
+ channels = []
337
+ for c in channel_ids:
338
+ attrs = info[f"Channel {c}"].attrs if f"Channel {c}" in info else {}
339
+ name = _attr_str(attrs, "Name")
340
+ color = None
341
+ rgb = (_attr_str(attrs, "Color") or "").split()
342
+ if len(rgb) >= 3:
343
+ try:
344
+ color = color_hex(tuple(float(v) for v in rgb[:3]))
345
+ except ValueError:
346
+ pass
347
+ channels.append(Channel(
348
+ name=None if not name or name == "(name not specified)" else name,
349
+ color=color,
350
+ emission_wavelength=quantity(_attr_str(attrs, "LSMEmissionWavelength"), "nanometer"),
351
+ excitation_wavelength=quantity(_attr_str(attrs, "LSMExcitationWavelength"),
352
+ "nanometer")))
353
+ return Metadata(pixel_sizes=sizes, position=position, channels=tuple(channels))
354
+
355
+
356
+ def _time_points(attrs) -> list:
357
+ """``TimePoint1`` .. ``TimePointN`` ('2020-02-29 00:07:15.969') as datetimes."""
358
+ import datetime
359
+ out = []
360
+ for n in range(1, len(attrs) + 1):
361
+ text = _attr_str(attrs, f"TimePoint{n}")
362
+ if text is None:
363
+ break
364
+ try:
365
+ out.append(datetime.datetime.fromisoformat(text.strip()))
366
+ except ValueError:
367
+ return []
368
+ return out
369
+
370
+
371
+ class _ImsFile(File):
372
+ backend = NAME
373
+
374
+ def __init__(self, path, images, h5):
375
+ super().__init__(path, images)
376
+ self._h5 = h5
377
+
378
+ def close(self) -> None:
379
+ super().close()
380
+ self._h5.close()
381
+
382
+
383
+ def open(path: str, tiles: str = "separate", resolutions: str = "base") -> _ImsFile: # noqa: A001 - backend protocol name
384
+ try:
385
+ import h5py
386
+ except ImportError as exc:
387
+ raise MicroReaderError(
388
+ "reading Imaris needs the [ims] extra: pip install 'micro-reader[ims]'") from exc
389
+ try:
390
+ h5 = h5py.File(path, "r")
391
+ except OSError as exc:
392
+ raise CorruptFileError(f"{path!r}: {exc}") from exc
393
+ try:
394
+ levels = sorted(int(name.rsplit(" ", 1)[1]) for name in h5.get("DataSet", {})
395
+ if name.startswith("ResolutionLevel "))
396
+ if not levels:
397
+ raise UnsupportedFormatError(f"{path!r}: HDF5 file without Imaris resolution levels")
398
+ listed = resolutions == "all"
399
+ pyramid = [_ImsImage(h5, path, r, key={"scene": 0, "resolution": r}
400
+ if (listed or r) else {"scene": 0}) for r in levels]
401
+ for image in pyramid:
402
+ image._resolutions = pyramid
403
+ images = pyramid if listed else pyramid[:1]
404
+ for n, image in enumerate(images):
405
+ image._position = n
406
+ except BaseException:
407
+ h5.close()
408
+ raise
409
+ return _ImsFile(path, images, h5)