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,104 @@
|
|
|
1
|
+
"""Backend selection: content first, file extension only as a fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import builtins
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ._base import SAMPLED_INDICES, File
|
|
9
|
+
from ._errors import UnsupportedFormatError
|
|
10
|
+
from .backends import czi, ims, lif, mrc, nd2, oir, raster, tiff
|
|
11
|
+
|
|
12
|
+
#: Tried in order: magic numbers before MRC, which may also match by
|
|
13
|
+
#: extension alone. Each backend module provides ``NAME``,
|
|
14
|
+
#: ``sniff(head: bytes, path: str) -> bool`` and
|
|
15
|
+
#: ``open(path, tiles="separate", resolutions="base") -> File``.
|
|
16
|
+
BACKENDS = [tiff, czi, ims, nd2, oir, lif, raster, mrc]
|
|
17
|
+
|
|
18
|
+
#: Bytes read from the start of the file for sniffing.
|
|
19
|
+
HEAD_BYTES = 1024
|
|
20
|
+
|
|
21
|
+
#: mosaic tiles as separate images (default) or composed, where the format
|
|
22
|
+
#: defines how (CZI); see docs/AXES.md
|
|
23
|
+
TILE_POLICIES = ("separate", "stitched")
|
|
24
|
+
|
|
25
|
+
#: resolution levels: full resolution only (default), or every level as its
|
|
26
|
+
#: own image indexed by "resolution" (image.resolutions gives a pyramid either way)
|
|
27
|
+
RESOLUTION_POLICIES = ("base", "all")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def open(path, backend: Optional[str] = None, *, tiles: str = "separate", # noqa: A001
|
|
31
|
+
resolutions: str = "base", rename: Optional[dict] = None,
|
|
32
|
+
axes: Optional[str] = None, samples: str = "keep", squeeze: bool = False) -> File:
|
|
33
|
+
"""Open *path* with the backend that recognises it (or the one named).
|
|
34
|
+
|
|
35
|
+
Each image of the file is one spatial frame with axes ``t c z y x`` (+ ``s``),
|
|
36
|
+
and an index saying which: scene, tile, rotation, view, and the
|
|
37
|
+
dimensions sampled at the same points (illumination, phase, block,
|
|
38
|
+
emission, excitation, lifetime, sequence). *tiles*: ``"separate"`` gives
|
|
39
|
+
each mosaic tile as its own image; ``"stitched"`` composes a scene's
|
|
40
|
+
tiles, for formats that define how (CZI), and is an error for tiled files
|
|
41
|
+
of other formats.
|
|
42
|
+
|
|
43
|
+
*rename* makes an index an array axis, for files that do not say what a
|
|
44
|
+
dimension is: ``rename={"sequence": "z"}`` reads a plain multi-page TIFF
|
|
45
|
+
as one z-stack. The target is ``t``, ``c`` or ``z``; a format that
|
|
46
|
+
cannot do it for that index raises.
|
|
47
|
+
|
|
48
|
+
*axes* (``"canonical"``, or letters such as ``"tczyx"``), *samples* and
|
|
49
|
+
*squeeze* present every image as ``Image.as_axes`` does. Each image's
|
|
50
|
+
``.source`` is then its native image."""
|
|
51
|
+
if tiles not in TILE_POLICIES:
|
|
52
|
+
raise ValueError(f"tiles={tiles!r}: one of {TILE_POLICIES}")
|
|
53
|
+
if resolutions not in RESOLUTION_POLICIES:
|
|
54
|
+
raise ValueError(f"resolutions={resolutions!r}: one of {RESOLUTION_POLICIES}")
|
|
55
|
+
rename = _check_rename(rename)
|
|
56
|
+
image = _open(os.fspath(path), backend, tiles, resolutions, rename)
|
|
57
|
+
left = sorted({name for index in image.indices for name in index if name in rename})
|
|
58
|
+
if left:
|
|
59
|
+
image.close()
|
|
60
|
+
raise UnsupportedFormatError(
|
|
61
|
+
f"rename: {path!r}: the {image.backend} backend cannot make {left} an axis")
|
|
62
|
+
if axes is not None or samples != "keep" or squeeze:
|
|
63
|
+
from ._axes import AxesImage
|
|
64
|
+
try:
|
|
65
|
+
image.images = [AxesImage(s, axes, samples=samples, squeeze=squeeze)
|
|
66
|
+
for s in image.images]
|
|
67
|
+
except BaseException:
|
|
68
|
+
image.close()
|
|
69
|
+
raise
|
|
70
|
+
return image
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_rename(rename: Optional[dict]) -> dict:
|
|
74
|
+
rename = {str(k).lower(): str(v).lower() for k, v in (rename or {}).items()}
|
|
75
|
+
for name, axis in rename.items():
|
|
76
|
+
if name not in SAMPLED_INDICES:
|
|
77
|
+
raise ValueError(f"rename: {name!r} is not an index that can become an axis "
|
|
78
|
+
f"(those are {SAMPLED_INDICES})")
|
|
79
|
+
if axis not in ("t", "c", "z"):
|
|
80
|
+
raise ValueError(f"rename {name!r}: {axis!r} is not 't', 'c' or 'z'")
|
|
81
|
+
if len(set(rename.values())) != len(rename):
|
|
82
|
+
raise ValueError(f"rename {rename}: two indices onto one axis")
|
|
83
|
+
return rename
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _open(path: str, backend: Optional[str], tiles: str, resolutions: str,
|
|
87
|
+
rename: dict) -> File:
|
|
88
|
+
def call(module):
|
|
89
|
+
if rename and getattr(module, "RENAMES", False):
|
|
90
|
+
return module.open(path, tiles, resolutions, rename=rename)
|
|
91
|
+
return module.open(path, tiles, resolutions)
|
|
92
|
+
|
|
93
|
+
if backend is not None:
|
|
94
|
+
for module in BACKENDS:
|
|
95
|
+
if module.NAME == backend:
|
|
96
|
+
return call(module)
|
|
97
|
+
raise UnsupportedFormatError(
|
|
98
|
+
f"unknown backend {backend!r}; available: {[m.NAME for m in BACKENDS]}")
|
|
99
|
+
with builtins.open(path, "rb") as fh:
|
|
100
|
+
head = fh.read(HEAD_BYTES)
|
|
101
|
+
for module in BACKENDS:
|
|
102
|
+
if module.sniff(head, path):
|
|
103
|
+
return call(module)
|
|
104
|
+
raise UnsupportedFormatError(f"no micro-reader backend recognises {path!r}")
|
micro_reader/_stream.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Streaming decode of compressed blocks too large to decode whole
|
|
2
|
+
(``_memory``; docs/PLAN.md, "memory-bounded reads of huge compressed blocks").
|
|
3
|
+
|
|
4
|
+
A block is decoded from its start, keeping only the bytes a read asks for;
|
|
5
|
+
the compressed bytes are read from the file in small pieces. Each decoder
|
|
6
|
+
keeps its place, and reads are handed the decoder of their block that is
|
|
7
|
+
closest before their start, so regions read top to bottom (as eubi-bridge's
|
|
8
|
+
writer reads) continue where the last one stopped: reading a whole plane
|
|
9
|
+
stays linear. Several reads of one block at once each get a decoder of
|
|
10
|
+
their own.
|
|
11
|
+
|
|
12
|
+
Checkpoints (deflate / zlib): the decoders of a block share an index of the
|
|
13
|
+
inflater's state every ``every`` decoded bytes (about 1/128 of the block, at
|
|
14
|
+
least 1 MB; ~43 KB each), so a read before a decoder's place -- a viewer
|
|
15
|
+
jumping around -- resumes from the nearest checkpoint instead of starting
|
|
16
|
+
the block again. Indexes of the ``MAX_INDEXES`` most recent blocks are kept.
|
|
17
|
+
|
|
18
|
+
Native: ``micro_reader_codecs.StreamDecoder`` (zlib / deflate, LZW, zstd,
|
|
19
|
+
PackBits, none). Reference, in Python: zlib / deflate (stdlib), LZMA
|
|
20
|
+
(stdlib), zstd (Python 3.14 or ``zstandard``), PackBits, none -- LZMA
|
|
21
|
+
always, the others when the native package is missing.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import threading
|
|
26
|
+
import zlib
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
|
|
29
|
+
from . import _codecs
|
|
30
|
+
from ._errors import CorruptFileError
|
|
31
|
+
|
|
32
|
+
#: codecs that can be streamed
|
|
33
|
+
STREAMABLE = ("zlib", "deflate", "lzw", "zstd", "packbits", "lzma", "none", "png")
|
|
34
|
+
NATIVE = ("zlib", "deflate", "lzw", "zstd", "packbits", "none", "png")
|
|
35
|
+
#: decoders kept for later reads (each holds a file handle and its window)
|
|
36
|
+
MAX_IDLE = 32
|
|
37
|
+
#: blocks whose checkpoint indexes are kept; checkpoints per block, spacing
|
|
38
|
+
MAX_INDEXES = 16
|
|
39
|
+
MAX_CHECKPOINTS = 128
|
|
40
|
+
MIN_CHECKPOINT_EVERY = 1 << 20
|
|
41
|
+
CHECKPOINTED = ("zlib", "deflate")
|
|
42
|
+
_PIECE = 1 << 16
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def streamable(codec: str) -> bool:
|
|
46
|
+
if codec not in STREAMABLE:
|
|
47
|
+
return False
|
|
48
|
+
if codec in NATIVE and _native_stream() is not None:
|
|
49
|
+
return True
|
|
50
|
+
return codec in ("zlib", "deflate", "lzma", "packbits", "none") or \
|
|
51
|
+
(codec == "zstd" and _reference_zstd_stream() is not None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _native_stream():
|
|
55
|
+
native = _codecs._native
|
|
56
|
+
return getattr(native, "StreamDecoder", None) if native is not None else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reference_zstd_stream():
|
|
60
|
+
try:
|
|
61
|
+
from compression import zstd # Python >= 3.14
|
|
62
|
+
return lambda: zstd.ZstdDecompressor()
|
|
63
|
+
except ImportError:
|
|
64
|
+
pass
|
|
65
|
+
try:
|
|
66
|
+
import zstandard
|
|
67
|
+
return lambda: zstandard.ZstdDecompressor().decompressobj()
|
|
68
|
+
except ImportError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _PackBits:
|
|
73
|
+
"""PackBits as a decompressor object (``decompress(data)``)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self.pending = b""
|
|
77
|
+
|
|
78
|
+
def decompress(self, data: bytes) -> bytes:
|
|
79
|
+
data, out, i = self.pending + data, bytearray(), 0
|
|
80
|
+
while i < len(data):
|
|
81
|
+
n = data[i] - 256 if data[i] > 127 else data[i]
|
|
82
|
+
if n >= 0:
|
|
83
|
+
if i + 1 + n + 1 > len(data):
|
|
84
|
+
break
|
|
85
|
+
out += data[i + 1:i + 2 + n]
|
|
86
|
+
i += n + 2
|
|
87
|
+
elif n != -128:
|
|
88
|
+
if i + 1 >= len(data):
|
|
89
|
+
break
|
|
90
|
+
out += bytes([data[i + 1]]) * (1 - n)
|
|
91
|
+
i += 2
|
|
92
|
+
else:
|
|
93
|
+
i += 1
|
|
94
|
+
self.pending = data[i:]
|
|
95
|
+
return bytes(out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _ReferenceIndex:
|
|
99
|
+
"""Checkpoints of one zlib / deflate block for the reference decoders:
|
|
100
|
+
(decoded position, compressed bytes consumed, decompressor copy)."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, every: int, max: int = MAX_CHECKPOINTS):
|
|
103
|
+
self.every, self.max, self.points = every, max, []
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def best(self, start: int):
|
|
107
|
+
with self._lock:
|
|
108
|
+
before = [p for p in self.points if p[0] <= start]
|
|
109
|
+
return max(before, key=lambda p: p[0]) if before else None
|
|
110
|
+
|
|
111
|
+
def record(self, position: int, consumed: int, decompressor) -> None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
if len(self.points) >= self.max or \
|
|
114
|
+
any(abs(p[0] - position) < self.every for p in self.points):
|
|
115
|
+
return
|
|
116
|
+
self.points.append((position, consumed, decompressor.copy()))
|
|
117
|
+
|
|
118
|
+
def __len__(self):
|
|
119
|
+
return len(self.points)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _ReferenceDecoder:
|
|
123
|
+
"""The native decoder's interface, decoded with Python's decompressor objects."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, path: str, offset: int, length: int, codec: str, index=None):
|
|
126
|
+
self.path, self.offset, self.length, self.codec = path, offset, length, codec
|
|
127
|
+
self.position, self.restarts, self.resumes = 0, 0, 0
|
|
128
|
+
self._index = index if codec in CHECKPOINTED else None
|
|
129
|
+
self._fh = None
|
|
130
|
+
self._lock = threading.Lock()
|
|
131
|
+
|
|
132
|
+
def _resume(self, point) -> None:
|
|
133
|
+
position, consumed, decompressor = point
|
|
134
|
+
if self._fh is not None:
|
|
135
|
+
self._fh.close()
|
|
136
|
+
self._fh = open(self.path, "rb") # noqa: SIM115 - kept open
|
|
137
|
+
self._fh.seek(self.offset + consumed)
|
|
138
|
+
self._left = self.length - consumed
|
|
139
|
+
self._pending = b""
|
|
140
|
+
self.position = position
|
|
141
|
+
self._decompressor = decompressor.copy()
|
|
142
|
+
self.resumes += 1
|
|
143
|
+
|
|
144
|
+
def _start(self):
|
|
145
|
+
import lzma
|
|
146
|
+
if self._fh is not None:
|
|
147
|
+
self.restarts += 1
|
|
148
|
+
self._fh.close()
|
|
149
|
+
self._fh = open(self.path, "rb") # noqa: SIM115 - kept open
|
|
150
|
+
self._fh.seek(self.offset)
|
|
151
|
+
self._left = self.length
|
|
152
|
+
self._pending = b""
|
|
153
|
+
self.position = 0
|
|
154
|
+
self._decompressor = {
|
|
155
|
+
"zlib": lambda: zlib.decompressobj(),
|
|
156
|
+
"deflate": lambda: zlib.decompressobj(-15),
|
|
157
|
+
"lzma": lambda: lzma.LZMADecompressor(),
|
|
158
|
+
"zstd": _reference_zstd_stream(),
|
|
159
|
+
"packbits": _PackBits,
|
|
160
|
+
"none": lambda: None,
|
|
161
|
+
}[self.codec]()
|
|
162
|
+
|
|
163
|
+
def _more(self) -> bytes:
|
|
164
|
+
"""The next decoded bytes; b"" at the end of the block."""
|
|
165
|
+
while True:
|
|
166
|
+
if not self._left:
|
|
167
|
+
return b""
|
|
168
|
+
data = self._fh.read(min(_PIECE, self._left))
|
|
169
|
+
self._left -= len(data)
|
|
170
|
+
if not data:
|
|
171
|
+
self._left = 0
|
|
172
|
+
return b""
|
|
173
|
+
if self._decompressor is None:
|
|
174
|
+
return data
|
|
175
|
+
try:
|
|
176
|
+
out = self._decompressor.decompress(data)
|
|
177
|
+
except Exception as exc: # noqa: BLE001 - codec specific
|
|
178
|
+
raise CorruptFileError(f"{self.path!r}: corrupt {self.codec} data: {exc}") from exc
|
|
179
|
+
if out:
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
def read_into(self, start: int, out) -> int:
|
|
183
|
+
view = memoryview(out).cast("B")
|
|
184
|
+
with self._lock:
|
|
185
|
+
behind = self._fh is None or start < self.position
|
|
186
|
+
point = self._index.best(start) if self._index is not None else None
|
|
187
|
+
if point is not None and (behind or point[0] > self.position):
|
|
188
|
+
self._resume(point)
|
|
189
|
+
elif behind:
|
|
190
|
+
self._start()
|
|
191
|
+
written = 0
|
|
192
|
+
while written < len(view):
|
|
193
|
+
if not self._pending:
|
|
194
|
+
if self._index is not None: # all decoded so far is delivered
|
|
195
|
+
self._index.record(self.position, self.length - self._left,
|
|
196
|
+
self._decompressor)
|
|
197
|
+
self._pending = self._more()
|
|
198
|
+
if not self._pending:
|
|
199
|
+
break
|
|
200
|
+
skip = start + written - self.position
|
|
201
|
+
if skip >= len(self._pending): # all before the start
|
|
202
|
+
self.position += len(self._pending)
|
|
203
|
+
self._pending = b""
|
|
204
|
+
continue
|
|
205
|
+
if skip > 0:
|
|
206
|
+
self.position += skip
|
|
207
|
+
self._pending = self._pending[skip:]
|
|
208
|
+
n = min(len(view) - written, len(self._pending))
|
|
209
|
+
view[written:written + n] = self._pending[:n]
|
|
210
|
+
self._pending = self._pending[n:]
|
|
211
|
+
self.position += n
|
|
212
|
+
written += n
|
|
213
|
+
return written
|
|
214
|
+
|
|
215
|
+
def close(self) -> None:
|
|
216
|
+
with self._lock:
|
|
217
|
+
if self._fh is not None:
|
|
218
|
+
self._fh.close()
|
|
219
|
+
self._fh = None
|
|
220
|
+
|
|
221
|
+
def __del__(self):
|
|
222
|
+
if getattr(self, "_fh", None) is not None:
|
|
223
|
+
self._fh.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_lock = threading.Lock()
|
|
227
|
+
#: (path, offset, codec) -> decoders not in use; least recently used first
|
|
228
|
+
_idle: "OrderedDict[tuple, list]" = OrderedDict()
|
|
229
|
+
#: (path, offset, codec) -> the block's checkpoint index; least recently used first
|
|
230
|
+
_indexes: "OrderedDict[tuple, object]" = OrderedDict()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _index(key: tuple, size) -> object:
|
|
234
|
+
"""The checkpoint index of block *key* (zlib / deflate), created on first use."""
|
|
235
|
+
if key[2] not in CHECKPOINTED:
|
|
236
|
+
return None
|
|
237
|
+
with _lock:
|
|
238
|
+
index = _indexes.get(key)
|
|
239
|
+
if index is None:
|
|
240
|
+
every = max(MIN_CHECKPOINT_EVERY, int(size or 0) // MAX_CHECKPOINTS)
|
|
241
|
+
native = _codecs._native
|
|
242
|
+
index = (native.BlockIndex(every, MAX_CHECKPOINTS)
|
|
243
|
+
if native is not None and hasattr(native, "BlockIndex")
|
|
244
|
+
else _ReferenceIndex(every))
|
|
245
|
+
_indexes[key] = index
|
|
246
|
+
while len(_indexes) > MAX_INDEXES:
|
|
247
|
+
_indexes.popitem(last=False)
|
|
248
|
+
else:
|
|
249
|
+
_indexes.move_to_end(key)
|
|
250
|
+
return index
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _new(path: str, offset: int, length: int, codec: str, index=None):
|
|
254
|
+
native = _native_stream()
|
|
255
|
+
if native is not None and codec in NATIVE:
|
|
256
|
+
if index is not None and not isinstance(index, _ReferenceIndex):
|
|
257
|
+
return native(path, offset, length, codec, index)
|
|
258
|
+
return native(path, offset, length, codec)
|
|
259
|
+
return _ReferenceDecoder(path, offset, length, codec,
|
|
260
|
+
index if isinstance(index, _ReferenceIndex) else None)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _acquire(key: tuple, length: int, start: int, size=None):
|
|
264
|
+
with _lock:
|
|
265
|
+
decoders = _idle.get(key, [])
|
|
266
|
+
before = [d for d in decoders if d.position <= start]
|
|
267
|
+
if before:
|
|
268
|
+
decoder = max(before, key=lambda d: d.position)
|
|
269
|
+
decoders.remove(decoder)
|
|
270
|
+
if not decoders:
|
|
271
|
+
_idle.pop(key, None)
|
|
272
|
+
return decoder
|
|
273
|
+
if decoders: # all past *start*: one resumes from a checkpoint
|
|
274
|
+
decoder = decoders.pop(0)
|
|
275
|
+
if not decoders:
|
|
276
|
+
_idle.pop(key, None)
|
|
277
|
+
return decoder
|
|
278
|
+
return _new(key[0], key[1], length, key[2], _index(key, size))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _release(key: tuple, decoder) -> None:
|
|
282
|
+
with _lock:
|
|
283
|
+
_idle.setdefault(key, []).append(decoder)
|
|
284
|
+
_idle.move_to_end(key)
|
|
285
|
+
total = sum(len(v) for v in _idle.values())
|
|
286
|
+
while total > MAX_IDLE:
|
|
287
|
+
oldest = next(iter(_idle))
|
|
288
|
+
_idle[oldest].pop(0)
|
|
289
|
+
if not _idle[oldest]:
|
|
290
|
+
del _idle[oldest]
|
|
291
|
+
total -= 1
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def read(path: str, offset: int, length: int, codec: str, start: int, out,
|
|
295
|
+
size: int = None) -> int:
|
|
296
|
+
"""Decoded bytes *start*... of the block (*length* compressed bytes at
|
|
297
|
+
*offset* of *path*, *codec*; *size* decoded bytes, if known) into *out*;
|
|
298
|
+
returns the bytes written (fewer at the end of the block)."""
|
|
299
|
+
key = (str(path), int(offset), codec)
|
|
300
|
+
decoder = _acquire(key, int(length), int(start), size)
|
|
301
|
+
try:
|
|
302
|
+
try:
|
|
303
|
+
return decoder.read_into(int(start), out)
|
|
304
|
+
except ValueError as exc:
|
|
305
|
+
raise CorruptFileError(f"{path!r}: corrupt {codec} data: {exc}") from exc
|
|
306
|
+
finally:
|
|
307
|
+
_release(key, decoder)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def forget(path: str) -> None:
|
|
311
|
+
"""Close and drop the decoders of *path* (its file was closed)."""
|
|
312
|
+
with _lock:
|
|
313
|
+
dropped = [d for key in [k for k in _idle if k[0] == str(path)] for d in _idle.pop(key)]
|
|
314
|
+
for key in [k for k in _indexes if k[0] == str(path)]:
|
|
315
|
+
del _indexes[key]
|
|
316
|
+
for decoder in dropped:
|
|
317
|
+
getattr(decoder, "close", lambda: None)()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Format backends. Each module provides NAME, sniff(head, path) and open(path)."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Metadata of CZI images (docs/METADATA.md), from the file's XML metadata
|
|
2
|
+
segment (``ZISRAWMETADATA``) and the first subblock's own metadata.
|
|
3
|
+
|
|
4
|
+
- Pixel sizes: ``Metadata/Scaling/Items/Distance`` X / Y / Z, stored in
|
|
5
|
+
metres; given in the file's display unit (``DefaultUnitFormat``, normally
|
|
6
|
+
µm) when OME-Zarr has a name for it, else micrometres.
|
|
7
|
+
- Time step: ``Information/Image/Dimensions/T/Positions/Interval/Increment``
|
|
8
|
+
(seconds).
|
|
9
|
+
- Channels: ``Information/Image/Dimensions/Channels/Channel`` (Name, Color,
|
|
10
|
+
Excitation / EmissionWavelength in nm), colours missing there from
|
|
11
|
+
``DisplaySetting/Channels``.
|
|
12
|
+
- Stage position: of the centre of the image's first pixel (docs/METADATA.md),
|
|
13
|
+
from ``StageXPosition`` / ``StageYPosition`` (µm) of the image's first
|
|
14
|
+
subblock, which Zeiss records for the centre of that field of view
|
|
15
|
+
(Bio-Formats reports it as is: the centre); files without it: the
|
|
16
|
+
scene's ``Positions/Position`` in the XML, taken as the image's centre.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import struct
|
|
21
|
+
from dataclasses import replace
|
|
22
|
+
import xml.etree.ElementTree as ET
|
|
23
|
+
|
|
24
|
+
from .._metadata import Channel, Metadata, Quantity, color_hex, quantity, unit_name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_xml(fh):
|
|
28
|
+
"""The root of the XML metadata segment of the CZI open as *fh*, or
|
|
29
|
+
None (no segment, unreadable XML)."""
|
|
30
|
+
fh.seek(32)
|
|
31
|
+
head = fh.read(80)
|
|
32
|
+
metadata_position = struct.unpack_from("<iiii16s16siqqiq", head)[8]
|
|
33
|
+
if not metadata_position:
|
|
34
|
+
return None
|
|
35
|
+
fh.seek(metadata_position)
|
|
36
|
+
seg = fh.read(32 + 8)
|
|
37
|
+
if seg[:14] != b"ZISRAWMETADATA":
|
|
38
|
+
return None
|
|
39
|
+
size, _attachment = struct.unpack_from("<ii", seg, 32)
|
|
40
|
+
fh.seek(metadata_position + 32 + 256)
|
|
41
|
+
try:
|
|
42
|
+
return ET.fromstring(fh.read(size))
|
|
43
|
+
except ET.ParseError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def subblock_tags(fh, position: int) -> dict:
|
|
48
|
+
"""The ``Tags`` of the subblock segment at *position*: {tag: text}."""
|
|
49
|
+
fh.seek(position)
|
|
50
|
+
head = fh.read(32 + 48)
|
|
51
|
+
if head[:14] != b"ZISRAWSUBBLOCK":
|
|
52
|
+
return {}
|
|
53
|
+
metadata_size, = struct.unpack_from("<i", head, 32)
|
|
54
|
+
ndims, = struct.unpack_from("<i", head, 32 + 44)
|
|
55
|
+
if metadata_size <= 0:
|
|
56
|
+
return {}
|
|
57
|
+
fh.seek(position + 32 + 16 + max(240, 32 + 20 * ndims))
|
|
58
|
+
try:
|
|
59
|
+
root = ET.fromstring(fh.read(metadata_size).rstrip(b"\x00"))
|
|
60
|
+
except ET.ParseError:
|
|
61
|
+
return {}
|
|
62
|
+
tags = root.find("Tags")
|
|
63
|
+
return {} if tags is None else {e.tag: (e.text or "").strip() for e in tags}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pixel_sizes(root) -> dict:
|
|
67
|
+
sizes = {}
|
|
68
|
+
if root is None:
|
|
69
|
+
return sizes
|
|
70
|
+
for d in root.findall("Metadata/Scaling/Items/Distance"):
|
|
71
|
+
axis = (d.get("Id") or "").lower()
|
|
72
|
+
if axis not in ("x", "y", "z"):
|
|
73
|
+
continue
|
|
74
|
+
q = quantity(d.findtext("Value"), "meter")
|
|
75
|
+
if q is None:
|
|
76
|
+
continue
|
|
77
|
+
shown = unit_name(d.findtext("DefaultUnitFormat"))
|
|
78
|
+
# the display unit when it is a length OME-Zarr names (and not
|
|
79
|
+
# larger than a millimetre: ``quantity``), else µm
|
|
80
|
+
try:
|
|
81
|
+
shown_q = q.to(shown) if shown else q
|
|
82
|
+
except KeyError:
|
|
83
|
+
shown_q = q
|
|
84
|
+
sizes[axis] = quantity(shown_q.value, shown_q.unit)
|
|
85
|
+
increment = root.findtext("Metadata/Information/Image/Dimensions/T/Positions/Interval/Increment")
|
|
86
|
+
q = quantity(increment, "second")
|
|
87
|
+
if q:
|
|
88
|
+
sizes["t"] = q
|
|
89
|
+
return sizes
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def channels(root, c_start: int, n: int) -> tuple:
|
|
93
|
+
"""Channels with indices c_start .. c_start + n - 1 of the file."""
|
|
94
|
+
found = []
|
|
95
|
+
display = []
|
|
96
|
+
if root is not None:
|
|
97
|
+
found = root.findall("Metadata/Information/Image/Dimensions/Channels/Channel")
|
|
98
|
+
display = root.findall("Metadata/DisplaySetting/Channels/Channel")
|
|
99
|
+
display_color = {}
|
|
100
|
+
for k, d in enumerate(display):
|
|
101
|
+
color = color_hex(d.findtext("Color"))
|
|
102
|
+
for name in (d.get("Name"), d.get("Id"), k):
|
|
103
|
+
display_color.setdefault(name, color)
|
|
104
|
+
out = []
|
|
105
|
+
for k in range(c_start, c_start + max(1, n)):
|
|
106
|
+
if k >= len(found):
|
|
107
|
+
out.append(Channel())
|
|
108
|
+
continue
|
|
109
|
+
c = found[k]
|
|
110
|
+
name = c.get("Name") or None
|
|
111
|
+
color = color_hex(c.findtext("Color"))
|
|
112
|
+
if color is None:
|
|
113
|
+
color = display_color.get(name) or display_color.get(c.get("Id"))
|
|
114
|
+
if color is None and len(display) == len(found):
|
|
115
|
+
color = display_color.get(k)
|
|
116
|
+
out.append(Channel(
|
|
117
|
+
name=name, color=color,
|
|
118
|
+
emission_wavelength=quantity(c.findtext("EmissionWavelength"), "nanometer"),
|
|
119
|
+
excitation_wavelength=quantity(c.findtext("ExcitationWavelength"), "nanometer")))
|
|
120
|
+
return tuple(out)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def scene_position(root, scene: int) -> dict:
|
|
124
|
+
"""The scene's stage position in the XML (µm): ``Positions/Position``."""
|
|
125
|
+
if root is None:
|
|
126
|
+
return {}
|
|
127
|
+
for e in root.findall("Metadata/Information/Image/Dimensions/S/Scenes/Scene"):
|
|
128
|
+
if e.get("Index") == str(scene):
|
|
129
|
+
p = e.find("Positions/Position")
|
|
130
|
+
if p is not None:
|
|
131
|
+
out = {a: quantity(p.get(a.upper()), "micrometer", positive=False)
|
|
132
|
+
for a in ("x", "y")}
|
|
133
|
+
return {a: q for a, q in out.items() if q}
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def position_from_tags(tags: dict) -> dict:
|
|
138
|
+
out = {}
|
|
139
|
+
for axis in ("x", "y"):
|
|
140
|
+
q = quantity(tags.get(f"Stage{axis.upper()}Position"), "micrometer", positive=False)
|
|
141
|
+
if q:
|
|
142
|
+
out[axis] = q
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def first_pixel_position(image, centre: dict, s, sizes: dict) -> dict:
|
|
147
|
+
"""Stage position of the centre of *image*'s first pixel, from *centre*:
|
|
148
|
+
the stage position of the centre of subblock *s* (Zeiss records where
|
|
149
|
+
the field of view's centre was). Pixel coordinates are one frame for
|
|
150
|
+
the whole file, so the offset between the two frames found at *s* holds
|
|
151
|
+
for the image's corner too."""
|
|
152
|
+
out = {}
|
|
153
|
+
origin = dict(zip("xy", image.origin))
|
|
154
|
+
extent = {"x": s.width, "y": s.height}
|
|
155
|
+
start = {"x": s.x, "y": s.y}
|
|
156
|
+
for axis, q in centre.items():
|
|
157
|
+
size = sizes.get(axis)
|
|
158
|
+
if size is None:
|
|
159
|
+
continue
|
|
160
|
+
step = size.to(q.unit).value
|
|
161
|
+
offset = q.value - (start[axis] + extent[axis] / 2) * step
|
|
162
|
+
out[axis] = Quantity(offset + (origin[axis] + 0.5) * step, q.unit)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def czi_metadata(image, fh, root) -> Metadata:
|
|
167
|
+
"""*image*'s Metadata; *fh* an open handle of its file, *root* the
|
|
168
|
+
file's XML metadata (or None)."""
|
|
169
|
+
axes = image.axes
|
|
170
|
+
n_c = image.shape[axes.index("c")] if "c" in axes else 1
|
|
171
|
+
sizes = pixel_sizes(root)
|
|
172
|
+
# the first subblock drawn of the first plane
|
|
173
|
+
first = image._planes[min(image._planes)].subblocks[0] if image._planes else None
|
|
174
|
+
position = {}
|
|
175
|
+
if first is not None and first.position >= 0:
|
|
176
|
+
centre = position_from_tags(subblock_tags(fh, first.position))
|
|
177
|
+
position = first_pixel_position(image, centre, first, sizes)
|
|
178
|
+
if not position and first is not None:
|
|
179
|
+
# no stage position per subblock: the scene's, taken as the centre of
|
|
180
|
+
# the whole scene (all its level-0 tiles; as Bio-Formats places it)
|
|
181
|
+
tiles = [e for e in image._czi._level0 if e.scene == first.scene]
|
|
182
|
+
x0, y0 = min(e.x for e in tiles), min(e.y for e in tiles)
|
|
183
|
+
x1 = max(e.x + e.width for e in tiles)
|
|
184
|
+
y1 = max(e.y + e.height for e in tiles)
|
|
185
|
+
whole = replace(first, x=x0, y=y0, width=x1 - x0, height=y1 - y0)
|
|
186
|
+
position = first_pixel_position(image, scene_position(root, first.scene), whole, sizes)
|
|
187
|
+
return Metadata(pixel_sizes=sizes, position=position,
|
|
188
|
+
channels=channels(root, image._czi._dim_starts.get("C", 0), n_c))
|