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,184 @@
1
+ """MRC (electron / light microscopy and cryo-EM volumes).
2
+
3
+ Written from the public MRC2014 specification (CCP-EM): a 1024-byte header,
4
+ an optional extended header of NSYMBT bytes, then raw pixels with X varying
5
+ fastest, then Y, then Z (sections). Uncompressed, so any rectangle is read
6
+ exactly -- only the bytes it covers, straight into the result. This
7
+ replaces a JVM / Bio-Formats read in eubi-bridge.
8
+
9
+ Supported: modes 0 (int8), 1 (int16), 2 (float32), 4 (complex64),
10
+ 6 (uint16), 12 (float16); little or big endian; extended headers; the
11
+ standard axis order (MAPC, MAPR, MAPS) = (1, 2, 3).
12
+
13
+ Orientation: MRC stores rows bottom-up (origin at the lower left, as IMOD
14
+ displays it). Images return them top-down, identical to Bio-Formats, so
15
+ switching eubi-bridge between the two never flips an image. (mrcfile and a
16
+ raw memmap return file order: row 0 there is our last row.)
17
+ Not yet: mode 3 (complex int16), mode 101 (4-bit), permuted axis orders.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import builtins
22
+ import os
23
+ import struct
24
+
25
+ import numpy as np
26
+
27
+ from .._base import File, Image
28
+ from .._errors import CorruptFileError, UnsupportedFormatError
29
+ from .._metadata import Channel, Metadata, quantity
30
+
31
+ NAME = "mrc"
32
+ HEADER_BYTES = 1024
33
+ CHUNK_BYTES = 4 << 20 # full-width rows are read this many bytes at a time
34
+ EXTENSIONS = {".mrc", ".mrcs", ".map", ".st", ".ali", ".rec"}
35
+
36
+ #: MRC2014 mode -> numpy dtype. Mode 0 is *signed* int8 in MRC2014; older
37
+ #: writers used it unsigned, which cannot be told apart from the header.
38
+ MODES = {0: "i1", 1: "i2", 2: "f4", 4: "c8", 6: "u2", 12: "f2"}
39
+ UNSUPPORTED_MODES = {3: "complex int16", 101: "4-bit packed"}
40
+
41
+
42
+ def sniff(head: bytes, path: str) -> bool:
43
+ """MRC2000+ files carry 'MAP ' at byte 208; older ones only the extension."""
44
+ if len(head) >= 212 and head[208:212] == b"MAP ":
45
+ return True
46
+ return os.path.splitext(path)[1].lower() in EXTENSIONS
47
+
48
+
49
+ def _byte_order(head: bytes) -> str:
50
+ """'<' or '>' from the machine stamp (byte 212), else by plausibility."""
51
+ stamp = head[212] if len(head) > 212 else 0
52
+ if stamp in (0x44, 0x41): # 0x44 0x44 (or 0x44 0x41): little endian
53
+ return "<"
54
+ if stamp == 0x11: # 0x11 0x11: big endian
55
+ return ">"
56
+ # No stamp (older writers): the order in which the mode is a known one
57
+ # and the dimensions are positive is the right one.
58
+ for order in ("<", ">"):
59
+ nx, ny, nz, mode = struct.unpack_from(order + "4i", head, 0)
60
+ if mode in MODES or mode in UNSUPPORTED_MODES:
61
+ if nx > 0 and ny > 0 and nz > 0:
62
+ return order
63
+ raise CorruptFileError("cannot determine the byte order of this MRC header")
64
+
65
+
66
+ class _MrcImage(Image):
67
+ """The whole MRC volume as one image, axes 'zyx'."""
68
+
69
+ def __init__(self, path: str, offset: int, shape, file_dtype: np.dtype, sizes=None):
70
+ super().__init__(0, "zyx", shape, file_dtype, name=os.path.basename(path))
71
+ if sizes is not None:
72
+ self._metadata = Metadata(pixel_sizes=sizes, channels=(Channel(),))
73
+ self._path = path
74
+ self._offset = offset
75
+ self._file_dtype = file_dtype
76
+ self._closed = False
77
+
78
+ def _read_block(self, block):
79
+ # Plain reads straight into the result, one per row segment. Not a
80
+ # memory map: mapped pages count as the process's memory (on Windows
81
+ # the whole file, after a full stream), which defeats the point of a
82
+ # memory-bounded reader. Each call opens its own handle, so reads
83
+ # are thread-safe and nothing holds the file between reads.
84
+ if self._closed:
85
+ raise ValueError("this MRC image has been closed: its file was closed "
86
+ "(File.close() or the end of a with block)")
87
+ zs, ys, xs = block
88
+ nz, ny, nx = self.shape
89
+ item = self._file_dtype.itemsize
90
+ out = np.empty((zs.stop - zs.start, ys.stop - ys.start, xs.stop - xs.start),
91
+ dtype=self._file_dtype)
92
+ # MRC's origin is the lower-left corner: the first row stored is the
93
+ # bottom of the image. Rows are returned top-down, as Bio-Formats
94
+ # (and so eubi-bridge, Fiji, OME-Zarr viewers) presents them: display
95
+ # row y is file row ny-1-y.
96
+ with builtins.open(self._path, "rb", buffering=0) as fh:
97
+ if xs.stop - xs.start == nx:
98
+ self._read_full_rows(fh, zs, ys, out)
99
+ else:
100
+ length = out.shape[2] * item
101
+ for i, z in enumerate(range(zs.start, zs.stop)):
102
+ for j, y in enumerate(range(ys.start, ys.stop)):
103
+ row = (z * ny + (ny - 1 - y)) * nx + xs.start
104
+ fh.seek(self._offset + row * item)
105
+ if fh.readinto(memoryview(out[i, j]).cast("B")) != length:
106
+ raise CorruptFileError(
107
+ f"{self._path!r}: short read at section {z}")
108
+ if not self._file_dtype.isnative:
109
+ out = out.byteswap(inplace=True).view(self.dtype)
110
+ return out, out.size
111
+
112
+ def _read_full_rows(self, fh, zs, ys, out):
113
+ """Full-width rows of a section are contiguous in the file: read them
114
+ in chunks of about CHUNK_BYTES and reverse each chunk into place.
115
+ Still exact; the only extra memory is one chunk."""
116
+ nz, ny, nx = self.shape
117
+ item = self._file_dtype.itemsize
118
+ per_chunk = max(1, CHUNK_BYTES // (nx * item))
119
+ scratch = np.empty((min(per_chunk, ys.stop - ys.start), nx), dtype=self._file_dtype)
120
+ first, stop = ny - ys.stop, ny - ys.start # file rows, bottom-up
121
+ for i, z in enumerate(range(zs.start, zs.stop)):
122
+ for f in range(first, stop, per_chunk):
123
+ n = min(per_chunk, stop - f)
124
+ fh.seek(self._offset + (z * ny + f) * nx * item)
125
+ if fh.readinto(memoryview(scratch[:n]).cast("B")) != n * nx * item:
126
+ raise CorruptFileError(f"{self._path!r}: short read at section {z}")
127
+ top = ny - f - n - ys.start # display row of file row f+n-1
128
+ out[i, top:top + n] = scratch[:n][::-1]
129
+
130
+ def close(self) -> None:
131
+ self._closed = True
132
+
133
+
134
+ class _MrcFile(File):
135
+ backend = NAME
136
+
137
+
138
+ def open(path: str, tiles: str = "separate", resolutions: str = "base") -> _MrcFile: # noqa: A001 - backend protocol name
139
+ size = os.path.getsize(path)
140
+ if size < HEADER_BYTES:
141
+ raise CorruptFileError(f"{path!r} is smaller than an MRC header")
142
+ with builtins.open(path, "rb") as fh:
143
+ head = fh.read(HEADER_BYTES)
144
+
145
+ order = _byte_order(head)
146
+ nx, ny, nz, mode = struct.unpack_from(order + "4i", head, 0)
147
+ mapc, mapr, maps = struct.unpack_from(order + "3i", head, 64)
148
+ nsymbt, = struct.unpack_from(order + "i", head, 92)
149
+
150
+ if mode in UNSUPPORTED_MODES:
151
+ raise UnsupportedFormatError(
152
+ f"MRC mode {mode} ({UNSUPPORTED_MODES[mode]}) is not supported yet")
153
+ if mode not in MODES:
154
+ raise CorruptFileError(f"unknown MRC mode {mode} in {path!r}")
155
+ if min(nx, ny, nz) <= 0:
156
+ raise CorruptFileError(f"invalid MRC dimensions {(nx, ny, nz)} in {path!r}")
157
+ if (mapc, mapr, maps) not in ((1, 2, 3), (0, 0, 0)):
158
+ raise UnsupportedFormatError(
159
+ f"MRC axis order (MAPC, MAPR, MAPS) = {(mapc, mapr, maps)} is not "
160
+ "supported yet; only the standard (1, 2, 3)")
161
+ if nsymbt < 0:
162
+ raise CorruptFileError(f"negative extended header size {nsymbt} in {path!r}")
163
+
164
+ dtype = np.dtype(MODES[mode]).newbyteorder(order)
165
+ offset = HEADER_BYTES + nsymbt
166
+ needed = offset + nx * ny * nz * dtype.itemsize
167
+ if size < needed:
168
+ raise CorruptFileError(
169
+ f"{path!r} is truncated: the header describes {needed} bytes, "
170
+ f"the file has {size}")
171
+ return _MrcFile(path, [_MrcImage(path, offset, (nz, ny, nx), dtype, _pixel_sizes(head, order))])
172
+
173
+
174
+ def _pixel_sizes(head: bytes, order: str) -> dict:
175
+ """Cell size (CELLA, Å) over the sampling (MX, MY, MZ): Å per pixel,
176
+ as MRC2014 defines it; kept in Å (OME-Zarr's ``angstrom``)."""
177
+ mx, my, mz = struct.unpack_from(order + "3i", head, 28)
178
+ cella = struct.unpack_from(order + "3f", head, 40)
179
+ sizes = {}
180
+ for axis, m, length in zip("xyz", (mx, my, mz), cella):
181
+ q = quantity(length / m, "angstrom") if m > 0 else None
182
+ if q:
183
+ sizes[axis] = q
184
+ return sizes
@@ -0,0 +1,264 @@
1
+ """Nikon ND2 (version 3 and later; legacy versions 1 / 2 in ``_nd2_legacy``), own reader.
2
+
3
+ A frame is one (time, position, Z) plane of all channels, stored in a chunk
4
+ ``ImageDataSeq|<n>!`` after an 8-byte timestamp: ``height`` rows of
5
+ ``width_bytes`` bytes (rows may be padded), each row ``width`` pixels of all
6
+ channels and RGB components interleaved. Frame numbers run over the
7
+ experiment loops, outermost first. Structure parsing is ported from nd2
8
+ (Talley Lambert, BSD-3), see ``_nd2_structure``.
9
+
10
+ - Uncompressed frames: only the requested rows are read (all channels
11
+ come along: they are interleaved per pixel).
12
+ - "Lossless" frames are one zlib stream each: decoded whole, cached.
13
+
14
+ Images are the XY positions, indexed by ``scene`` (as bioio-nd2 has them); axes ``tczyx``, plus ``s``
15
+ for RGB components. Legacy JPEG 2000 ND2 (versions 1 / 2): ``_nd2_legacy``.
16
+ Not supported:
17
+ lossy compression, tiled frames, custom loops.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import builtins
22
+ import itertools
23
+ import os
24
+ import threading
25
+ import zlib
26
+
27
+ import numpy as np
28
+
29
+ from .. import _memory, _pool, _stream
30
+ from .._base import File, Image
31
+ from .._errors import CorruptFileError, UnsupportedFormatError
32
+ from .._io import DecodeCache, ThreadHandles
33
+ from . import _nd2_legacy as legacy
34
+ from . import _nd2_structure as structure
35
+
36
+ NAME = "nd2"
37
+ DECODE_CACHE_BYTES = 64 << 20
38
+ RAW_TASK_BYTES = 8 << 20 # uncompressed frames are read in row pieces of this size
39
+ STREAM_PIECE_BYTES = 8 << 20 # rows of a streamed "lossless" frame decoded at a time
40
+ TIMESTAMP_BYTES = 8 # each frame chunk starts with a float64 timestamp
41
+
42
+
43
+ def sniff(head: bytes, path: str) -> bool:
44
+ if head[:4] == structure.CHUNK_MAGIC.to_bytes(4, "little"):
45
+ return True
46
+ # legacy ND2 is a JPEG 2000 box file: the JP2 signature alone would also
47
+ # claim plain .jp2 images, so the extension decides (the box map at the
48
+ # end is checked on opening)
49
+ return legacy.is_legacy(head) and path.lower().endswith(".nd2")
50
+
51
+
52
+ class Nd2Parser:
53
+ """What the images of one file share: layout, frame offsets, handles."""
54
+
55
+ def __init__(self, path: str):
56
+ self.path = path
57
+ with builtins.open(path, "rb") as fh:
58
+ ver = structure.version(fh.read(structure.START_CHUNK.size))
59
+ if ver < (3, 0):
60
+ raise UnsupportedFormatError(f"{path!r}: legacy ND2 version {ver}")
61
+ cmap = structure.chunkmap(fh)
62
+
63
+ def decoded(name):
64
+ if name not in cmap:
65
+ return {}
66
+ return structure.decode_clx_lite(structure.read_chunk(fh, cmap[name][0]))
67
+
68
+ picture = decoded(b"ImageMetadataSeqLV|0!")
69
+ experiment = decoded(b"ImageMetadataLV!")
70
+ self.attrs = structure.attributes(decoded(b"ImageAttributesLV!"), picture)
71
+ self.loops = structure.experiment(experiment)
72
+ #: raw picture metadata and experiment, for ``_nd2_metadata``
73
+ self.picture = picture.get("SLxPictureMetadata", picture)
74
+ self.experiment = experiment
75
+ if self.attrs.compression == "lossy":
76
+ raise UnsupportedFormatError(f"{path!r}: lossy ND2 compression")
77
+ a = self.attrs
78
+ if a.components % a.channels:
79
+ raise UnsupportedFormatError(f"{path!r}: {a.components} components "
80
+ f"for {a.channels} channels")
81
+ self.samples = a.components // a.channels
82
+ self.dtype = np.dtype(a.dtype)
83
+ self.pixel_bytes = a.components * self.dtype.itemsize
84
+ if a.width_bytes < a.width * self.pixel_bytes:
85
+ raise CorruptFileError(f"{path!r}: rows of {a.width_bytes} bytes cannot hold "
86
+ f"{a.width} pixels of {self.pixel_bytes} bytes")
87
+ kinds = [loop.kind for loop in self.loops]
88
+ if len(set(kinds)) != len(kinds):
89
+ raise UnsupportedFormatError(f"{path!r}: repeated experiment loops {kinds}")
90
+ # frame chunk -> offset of its header; data offsets are read lazily
91
+ self.frames = {int(name[13:-1]): offset for name, (offset, _) in cmap.items()
92
+ if name.startswith(b"ImageDataSeq|")}
93
+ self._data_at: dict = {}
94
+ self._lock = threading.Lock()
95
+ self.handles = ThreadHandles(path)
96
+
97
+ def size(self, kind: str) -> int:
98
+ return next((loop.count for loop in self.loops if loop.kind == kind), 1)
99
+
100
+ def frame_number(self, t: int, p: int, z: int) -> int:
101
+ index = {"T": t, "P": p, "Z": z}
102
+ number = 0
103
+ for loop in self.loops:
104
+ number = number * loop.count + index[loop.kind]
105
+ return number
106
+
107
+ def frame_data(self, number: int):
108
+ """(offset of the frame's pixels or compressed stream, its length),
109
+ or None for a frame that was never written."""
110
+ where = self._data_at.get(number)
111
+ if where is None:
112
+ header = self.frames.get(number)
113
+ if header is None:
114
+ return None
115
+ fh = self.handles.get()
116
+ start, length = structure.data_offset(fh, header)
117
+ where = self._data_at[number] = (start + TIMESTAMP_BYTES, length - TIMESTAMP_BYTES)
118
+ return where
119
+
120
+
121
+ class _Nd2Image(Image):
122
+ """One XY position: axes 'tczyx' (+ 's' for RGB components)."""
123
+
124
+ def __init__(self, nd2: Nd2Parser, position: int):
125
+ a = nd2.attrs
126
+ self._f = nd2
127
+ self._p = position
128
+ self._rgb = nd2.samples > 1
129
+ shape = (nd2.size("T"), a.channels, nd2.size("Z"), a.height, a.width)
130
+ axes = "tczyx"
131
+ if self._rgb:
132
+ shape, axes = shape + (nd2.samples,), axes + "s"
133
+ #: decoded "lossless" frames kept for reuse
134
+ self.decode_cache = DecodeCache(DECODE_CACHE_BYTES)
135
+ self._closed = False
136
+ unit = (1, a.channels, 1, a.height, a.width) + ((nd2.samples,) if self._rgb else ()) \
137
+ if a.compression == "lossless" else None
138
+ super().__init__(position, axes, shape, nd2.dtype,
139
+ name=f"{os.path.basename(nd2.path)} position {position}",
140
+ read_unit=unit)
141
+
142
+ def _read_block(self, block):
143
+ if self._closed:
144
+ raise ValueError("this ND2 image has been closed: its file was closed "
145
+ "(File.close() or the end of a with block)")
146
+ ts, cs, zs, ys, xs = block[:5]
147
+ ss = block[5] if self._rgb else slice(0, 1)
148
+ out = np.empty(tuple(s.stop - s.start for s in block), dtype=self.dtype)
149
+ view = out if self._rgb else out[..., None] # always (..., s)
150
+ tasks = []
151
+ for t, z in itertools.product(range(ts.start, ts.stop), range(zs.start, zs.stop)):
152
+ dst = view[t - ts.start, :, z - zs.start] # (c, y, x, s)
153
+ where = self._f.frame_data(self._f.frame_number(t, self._p, z))
154
+ if where is None:
155
+ dst[...] = 0
156
+ elif self._f.attrs.compression == "lossless":
157
+ tasks.append(lambda a=(t, z, where, dst, cs, ys, xs, ss): self._decoded_into(*a))
158
+ else:
159
+ tasks.extend(self._raw_tasks(where[0], dst, cs, ys, xs, ss))
160
+ return out, _pool.run(tasks)
161
+
162
+ def _frame_view(self, buf: np.ndarray, rows: int) -> np.ndarray:
163
+ """Bytes of *rows* whole rows -> (rows, x, channel, sample)."""
164
+ f, a = self._f, self._f.attrs
165
+ pixels = buf.reshape(rows, a.width_bytes)[:, :a.width * f.pixel_bytes]
166
+ return pixels.view(f.dtype).reshape(rows, a.width, a.channels, f.samples)
167
+
168
+ def _raw_tasks(self, start, dst, cs, ys, xs, ss):
169
+ f, a = self._f, self._f.attrs
170
+ per_task = max(1, RAW_TASK_BYTES // a.width_bytes)
171
+ whole_rows = (xs.stop - xs.start) * 2 >= a.width # else: only the needed bytes per row
172
+ for y0 in range(ys.start, ys.stop, per_task):
173
+ y1 = min(ys.stop, y0 + per_task)
174
+ part = dst[:, y0 - ys.start:y1 - ys.start]
175
+ yield lambda args=(start, y0, y1, part, cs, xs, ss, whole_rows): self._read_rows(*args)
176
+
177
+ def _read_rows(self, start, y0, y1, dst, cs, xs, ss, whole_rows) -> int:
178
+ """Rows y0:y1 of an uncompressed frame into *dst* (c, y, x, s)."""
179
+ f, a = self._f, self._f.attrs
180
+ fh = f.handles.get()
181
+ n = y1 - y0
182
+ if whole_rows:
183
+ buf = np.empty(n * a.width_bytes, np.uint8)
184
+ fh.seek(start + y0 * a.width_bytes)
185
+ if fh.readinto(memoryview(buf)) != buf.nbytes:
186
+ raise CorruptFileError(f"{f.path!r}: frame data ends early")
187
+ pixels = self._frame_view(buf, n)[:, xs]
188
+ read = n * a.width * a.components
189
+ else:
190
+ width = xs.stop - xs.start
191
+ buf = np.empty((n, width * f.pixel_bytes), np.uint8)
192
+ for i in range(n):
193
+ fh.seek(start + (y0 + i) * a.width_bytes + xs.start * f.pixel_bytes)
194
+ if fh.readinto(memoryview(buf[i])) != buf.shape[1]:
195
+ raise CorruptFileError(f"{f.path!r}: frame data ends early")
196
+ pixels = buf.view(f.dtype).reshape(n, width, a.channels, f.samples)
197
+ read = n * width * a.components
198
+ dst[...] = pixels[:, :, cs, ss].transpose(2, 0, 1, 3)
199
+ return read
200
+
201
+ def _decoded_into(self, t, z, where, dst, cs, ys, xs, ss) -> int:
202
+ frame, read = self.decode_cache.get((t, z)), 0
203
+ a = self._f.attrs
204
+ if frame is None and _memory.should_stream(a.height * a.width_bytes):
205
+ return self._streamed_into(where, dst, cs, ys, xs, ss)
206
+ if frame is None:
207
+ f, a = self._f, self._f.attrs
208
+ with _memory.reserve(a.height * a.width_bytes, f"{f.path!r}: an ND2 frame"):
209
+ data = zlib.decompress(f.handles.read(*where), 15, a.height * a.width_bytes)
210
+ if len(data) < a.height * a.width_bytes:
211
+ raise CorruptFileError(f"{f.path!r}: frame decodes to {len(data)} bytes")
212
+ frame = self._frame_view(np.frombuffer(data, np.uint8)[:a.height * a.width_bytes],
213
+ a.height)
214
+ self.decode_cache.put((t, z), frame)
215
+ read = frame.size
216
+ dst[...] = frame[ys, xs][:, :, cs, ss].transpose(2, 0, 1, 3)
217
+ return read
218
+
219
+ def _read_metadata(self):
220
+ from ._nd2_metadata import nd2_metadata
221
+ return nd2_metadata(self._f.picture, self._f.experiment, self._p,
222
+ dict(zip(self.axes, self.shape)))
223
+
224
+ def _streamed_into(self, where, dst, cs, ys, xs, ss) -> int:
225
+ """Rows *ys* of a "lossless" frame too large to decode whole: its zlib
226
+ stream decoded as a stream, in pieces of rows (``_stream``)."""
227
+ f, a = self._f, self._f.attrs
228
+ per_piece = max(1, min(STREAM_PIECE_BYTES, _memory.memory_limit() // 4) // a.width_bytes)
229
+ for y0 in range(ys.start, ys.stop, per_piece):
230
+ y1 = min(ys.stop, y0 + per_piece)
231
+ with _memory.reserve((y1 - y0) * a.width_bytes, f"{f.path!r}: rows of a frame"):
232
+ buf = np.empty((y1 - y0) * a.width_bytes, np.uint8)
233
+ n = _stream.read(f.path, where[0], where[1], "zlib", y0 * a.width_bytes, buf,
234
+ size=a.height * a.width_bytes)
235
+ if n < buf.nbytes:
236
+ raise CorruptFileError(f"{f.path!r}: frame ends at {n} of {buf.nbytes} bytes")
237
+ rows = self._frame_view(buf, y1 - y0)
238
+ dst[:, y0 - ys.start:y1 - ys.start] = rows[:, xs][:, :, cs, ss].transpose(2, 0, 1, 3)
239
+ return (ys.stop - ys.start) * a.width * a.components
240
+
241
+ def close(self) -> None:
242
+ self._closed = True
243
+ self.decode_cache.clear()
244
+ _stream.forget(self._f.path)
245
+
246
+
247
+ class _Nd2File(File):
248
+ backend = NAME
249
+
250
+ def __init__(self, path, images, nd2: Nd2Parser):
251
+ super().__init__(path, images)
252
+ self._nd2 = nd2
253
+
254
+ def close(self) -> None:
255
+ super().close()
256
+ self._nd2.handles.close()
257
+
258
+
259
+ def open(path: str, tiles: str = "separate", resolutions: str = "base"): # noqa: A001 - backend protocol name
260
+ with builtins.open(path, "rb") as fh:
261
+ if legacy.is_legacy(fh.read(12)):
262
+ return legacy._LegacyNd2File(path)
263
+ nd2 = Nd2Parser(path)
264
+ return _Nd2File(path, [_Nd2Image(nd2, p) for p in range(nd2.size("P"))], nd2)