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.
- micro_reader/__init__.py +23 -0
- micro_reader/_axes.py +196 -0
- micro_reader/_base.py +268 -0
- micro_reader/_codecs.py +439 -0
- micro_reader/_errors.py +14 -0
- micro_reader/_index.py +53 -0
- micro_reader/_io.py +83 -0
- micro_reader/_jpeg_restarts.py +174 -0
- micro_reader/_memory.py +161 -0
- micro_reader/_metadata.py +163 -0
- micro_reader/_pool.py +85 -0
- micro_reader/_registry.py +104 -0
- micro_reader/_stream.py +317 -0
- micro_reader/backends/__init__.py +1 -0
- micro_reader/backends/_czi_metadata.py +188 -0
- micro_reader/backends/_lif_metadata.py +125 -0
- micro_reader/backends/_nd2_legacy.py +370 -0
- micro_reader/backends/_nd2_metadata.py +224 -0
- micro_reader/backends/_nd2_structure.py +280 -0
- micro_reader/backends/_tiff_metadata.py +287 -0
- micro_reader/backends/czi.py +948 -0
- micro_reader/backends/ims.py +409 -0
- micro_reader/backends/lif.py +713 -0
- micro_reader/backends/mrc.py +184 -0
- micro_reader/backends/nd2.py +264 -0
- micro_reader/backends/oir.py +439 -0
- micro_reader/backends/raster.py +568 -0
- micro_reader/backends/tiff.py +858 -0
- micro_reader-0.0.1.dist-info/METADATA +741 -0
- micro_reader-0.0.1.dist-info/RECORD +32 -0
- micro_reader-0.0.1.dist-info/WHEEL +4 -0
- micro_reader-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|