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,23 @@
1
+ """micro-reader: read exactly the rectangle you ask for.
2
+
3
+ >>> import micro_reader
4
+ >>> with micro_reader.open("image.mrc") as f:
5
+ ... block = f.images[0].read(z=0, y=slice(0, 512))
6
+ """
7
+ from ._base import INDEX_NAMES, SAMPLED_INDICES, File, Image, ReadStats
8
+ from ._metadata import Channel, Metadata, Quantity
9
+ from ._errors import CorruptFileError, MicroReaderError, UnsupportedFormatError
10
+ from ._memory import MemoryLimitError, memory_limit, set_memory_limit
11
+ from ._pool import decode_threads, set_decode_threads
12
+ from ._registry import BACKENDS, open # noqa: A004 - the public entry point
13
+ from .backends.czi import DamagedFileWarning
14
+
15
+ __version__ = "0.0.1"
16
+
17
+ __all__ = [
18
+ "open", "File", "Image", "INDEX_NAMES", "SAMPLED_INDICES", "ReadStats", "BACKENDS",
19
+ "Metadata", "Channel", "Quantity",
20
+ "decode_threads", "set_decode_threads", "memory_limit", "set_memory_limit",
21
+ "MicroReaderError", "UnsupportedFormatError", "CorruptFileError", "MemoryLimitError", "DamagedFileWarning",
22
+ "__version__",
23
+ ]
micro_reader/_axes.py ADDED
@@ -0,0 +1,196 @@
1
+ """An image presented with chosen axes (``Image.as_axes``, ``Image.squeeze``,
2
+ ``open(axes=..., squeeze=...)``).
3
+
4
+ Arrays have only ``t c z y x`` and ``s`` (RGB samples); every other
5
+ dimension a file has is an image index (docs/AXES.md). Backends report the
6
+ axes an image has: formats with an order of their own (TIFF, MRC) in that
7
+ order, the others in ``tczyx``. An axes image rearranges them without
8
+ guessing:
9
+
10
+ - ``axes=None`` keeps the image's order; ``"canonical"`` gives ``t c z y x``,
11
+ then ``s``; any other string is the exact axes wanted;
12
+ - axes the file lacks are inserted with size 1;
13
+ - RGB samples ``s`` stay a trailing ``s`` when asked for, or are folded into
14
+ the channels with ``samples="channels"`` (channel ``k`` = sample ``k % S``
15
+ of channel ``k // S``, as eubi-bridge stores them);
16
+ - ``squeeze=True`` drops every size-1 axis except ``y`` and ``x``;
17
+ ``squeeze="tz"`` drops just those (each must have size 1);
18
+ - an axis the requested ones cannot hold is an error that names it.
19
+
20
+ Reads stay exact: each request is translated into the native axes, read
21
+ there, and rearranged; the native image stays available as ``.source``.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Optional, Union
26
+
27
+ import numpy as np
28
+
29
+ from ._base import Image
30
+
31
+ SAMPLE_POLICIES = ("keep", "channels")
32
+ #: the array axes, outermost first; ``s`` (RGB samples) after them
33
+ STANDARD = "tczyx"
34
+ CANONICAL = STANDARD + "s"
35
+ #: never dropped by ``squeeze=True``: a plane stays a plane
36
+ KEPT_BY_SQUEEZE = "yx"
37
+
38
+
39
+ class AxesImage(Image):
40
+ """*source* presented with *axes* (see the module docstring)."""
41
+
42
+ def __init__(self, source: Image, axes: Optional[str] = None, *, samples: str = "keep",
43
+ squeeze: Union[bool, str] = False, _select: Optional[dict] = None):
44
+ if samples not in SAMPLE_POLICIES:
45
+ raise ValueError(f"samples={samples!r}: one of {SAMPLE_POLICIES}")
46
+ names = list(source.axes)
47
+ #: native size-1 axes left out (squeezed): read at index 0
48
+ select = dict(_select or {})
49
+ axes = self._resolve(source, axes, names, select, samples)
50
+ self.source = source
51
+ self._names, self._select = names, select
52
+ self._samples_policy = samples
53
+ sizes = dict(zip(names, source.shape))
54
+ # RGB samples folded into the channels
55
+ self._fold = "s" in names and "s" not in axes and "s" not in select \
56
+ and samples == "channels"
57
+ if self._fold:
58
+ sizes["c"] = sizes.get("c", 1) * sizes.pop("s")
59
+ left = [a for a in names if a not in select and a not in axes
60
+ and not (self._fold and a == "s") and not (self._fold and a == "c")]
61
+ if left:
62
+ hint = ("; samples='channels' folds RGB samples into the channels"
63
+ if "s" in left else "")
64
+ raise ValueError(f"{source.name!r} has axes {''.join(left)!r} that {axes!r} cannot "
65
+ f"hold{hint}")
66
+ if self._fold and "c" not in axes:
67
+ raise ValueError(f"samples='channels' needs 'c' in the axes (got {axes!r})")
68
+ if squeeze:
69
+ axes = self._squeeze(axes, sizes, squeeze, names, select, source.name)
70
+ shape = [sizes.get(a, 1) for a in axes]
71
+ self._samples = source.shape[names.index("s")] if self._fold else 1
72
+ unit = None
73
+ if source.read_unit is not None:
74
+ native_unit = dict(zip(names, source.read_unit))
75
+ if self._fold:
76
+ native_unit["c"] = native_unit.get("c", 1) * native_unit.pop("s")
77
+ unit = [native_unit.get(a, 1) for a in axes]
78
+ super().__init__(source._position, axes, shape, source.dtype, name=source.name,
79
+ read_unit=unit, index=source.index)
80
+
81
+ @staticmethod
82
+ def _resolve(source, axes, names, select, samples) -> str:
83
+ """The axes string asked for: None, "canonical" or explicit letters."""
84
+ free = [a for a in names if a not in select]
85
+ fold = samples == "channels" and "s" in free
86
+ if axes is None: # the image's own order
87
+ if fold:
88
+ free.remove("s")
89
+ if "c" not in free: # RGB without channels: c
90
+ at = next((i for i, a in enumerate(free) if a in "zyx"), len(free))
91
+ free.insert(at, "c") # where the canonical order has it
92
+ return "".join(free)
93
+ axes = axes.lower()
94
+ if axes == "canonical":
95
+ return STANDARD + ("s" if "s" in free and not fold else "")
96
+ if len(set(axes)) != len(axes) or not axes.isalpha():
97
+ raise ValueError(f"axes {axes!r}: one letter per axis, each once")
98
+ stray = sorted(set(axes) - set(CANONICAL))
99
+ if stray:
100
+ raise ValueError(f"axes {axes!r}: {stray} are not array axes (arrays have "
101
+ f"{CANONICAL!r}; other dimensions are image indices)")
102
+ return axes
103
+
104
+ def _squeeze(self, axes, sizes, squeeze, names, select, name) -> str:
105
+ """*axes* without the squeezed ones; squeezed native axes are read at
106
+ index 0 (*select* is updated in place)."""
107
+ if squeeze is True:
108
+ drop = [a for a in axes if sizes.get(a, 1) == 1 and a not in KEPT_BY_SQUEEZE]
109
+ else:
110
+ drop = list(squeeze.lower())
111
+ for a in drop:
112
+ if a not in axes:
113
+ raise ValueError(f"squeeze {a!r}: {name!r} has axes {axes!r}")
114
+ if sizes.get(a, 1) != 1:
115
+ raise ValueError(f"squeeze {a!r}: axis {a!r} of {name!r} has "
116
+ f"{sizes[a]}, not 1")
117
+ for a in drop:
118
+ if a in names and not (self._fold and a == "c"):
119
+ select[a] = 0
120
+ return "".join(a for a in axes if a not in drop)
121
+
122
+ def _read_block(self, block):
123
+ names, select, axes = self._names, self._select, self.axes
124
+ want = dict(zip(axes, block))
125
+ S = self._samples
126
+ if self._fold: # folded channels k0:k1 -> channels, all samples
127
+ k = want.get("c", slice(0, 1))
128
+ c0, c1 = k.start // S, (k.stop - 1) // S + 1
129
+ native_key = []
130
+ for a, size in zip(names, self.source.shape):
131
+ if a in select:
132
+ native_key.append(slice(select[a], select[a] + 1))
133
+ elif self._fold and a == "s":
134
+ native_key.append(slice(0, S))
135
+ elif self._fold and a == "c":
136
+ native_key.append(slice(c0, c1))
137
+ else:
138
+ native_key.append(want[a])
139
+ data, read = self.source._read_block(tuple(native_key))
140
+ order = list(names)
141
+ # drop squeezed axes
142
+ for a in select:
143
+ data = data.squeeze(axis=order.index(a))
144
+ order.remove(a)
145
+ if self._fold:
146
+ if "c" not in order: # RGB without channels: one channel
147
+ data = np.expand_dims(data, order.index("s"))
148
+ order.insert(order.index("s"), "c")
149
+ ci, si = order.index("c"), order.index("s")
150
+ data = np.moveaxis(data, si, ci + 1 if si > ci else ci)
151
+ order.remove("s")
152
+ ci = order.index("c")
153
+ shape = list(data.shape)
154
+ shape[ci:ci + 2] = [shape[ci] * shape[ci + 1]]
155
+ data = data.reshape(shape)
156
+ k = want.get("c", slice(0, 1))
157
+ index = [slice(None)] * data.ndim
158
+ index[ci] = slice(k.start - c0 * S, k.stop - c0 * S)
159
+ data = data[tuple(index)]
160
+ data = data.transpose([order.index(a) for a in axes if a in order])
161
+ for i, a in enumerate(axes): # axes the file lacks
162
+ if a not in order:
163
+ data = np.expand_dims(data, i)
164
+ return np.ascontiguousarray(data), read
165
+
166
+ @property
167
+ def metadata(self):
168
+ """The native image's metadata (channels follow its own c axis)."""
169
+ return self.source.metadata
170
+
171
+ @property
172
+ def resolutions(self) -> list:
173
+ """The native image's pyramid, each level with these axes."""
174
+ levels = self.source.resolutions
175
+ if len(levels) == 1:
176
+ return [self]
177
+ return [self if level is self.source else
178
+ AxesImage(level, self.axes, samples=self._samples_policy,
179
+ _select=dict(self._select))
180
+ for level in levels]
181
+
182
+ def as_axes(self, axes: Optional[str] = None, **policies) -> "AxesImage":
183
+ """An axes image of the native image with other axes (they do not stack)."""
184
+ return self.source.as_axes(axes, **policies)
185
+
186
+ def squeeze(self, axes: Optional[str] = None) -> "AxesImage":
187
+ """This axes image without its size-1 axes (all but ``y`` / ``x``, or those
188
+ in *axes*), rebuilt over the native image."""
189
+ return AxesImage(self.source, self.axes, samples=self._samples_policy,
190
+ squeeze=axes or True, _select=dict(self._select))
191
+
192
+ def close(self) -> None:
193
+ self.source.close()
194
+
195
+ def __repr__(self) -> str:
196
+ return super().__repr__()[:-1] + f" of {self.source.axes!r}>"
micro_reader/_base.py ADDED
@@ -0,0 +1,268 @@
1
+ """The interface every backend implements: a File holding Images.
2
+
3
+ Each image is one spatial frame of the file; its index says which (scene,
4
+ tile, rotation, view: see docs/AXES.md)."""
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional, Sequence
10
+
11
+ import numpy as np
12
+
13
+ from ._index import normalise_index
14
+
15
+
16
+ @dataclass
17
+ class ReadStats:
18
+ """What an image has read so far; the over-read check of the test harness.
19
+
20
+ ``pixels_returned`` counts the contiguous spans requested (before any step
21
+ subsampling); ``pixels_read`` what the backend actually had to read or
22
+ decode. A backend that reads exact rectangles keeps them equal.
23
+ """
24
+ reads: int = 0
25
+ pixels_returned: int = 0
26
+ pixels_read: int = 0
27
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False,
28
+ compare=False)
29
+
30
+ @property
31
+ def over_read(self) -> float:
32
+ return (self.pixels_read / self.pixels_returned
33
+ if self.pixels_returned else 1.0)
34
+
35
+ def add(self, returned: int, read: int) -> None:
36
+ with self._lock:
37
+ self.reads += 1
38
+ self.pixels_returned += returned
39
+ self.pixels_read += read
40
+
41
+ def reset(self) -> None:
42
+ with self._lock:
43
+ self.reads = self.pixels_returned = self.pixels_read = 0
44
+
45
+
46
+ class Image:
47
+ """One image of a file (one spatial frame), read by rectangle.
48
+
49
+ Backends implement ``_read_block``; everything numpy-like -- integer
50
+ indices, steps, Ellipsis, the keyword form of ``read`` -- is handled here.
51
+ Results are always native-endian and never views into file-backed memory.
52
+ """
53
+
54
+ def __init__(self, position: int, axes: str, shape: Sequence[int], dtype,
55
+ name: Optional[str] = None,
56
+ read_unit: Optional[Sequence[int]] = None,
57
+ index: Optional[dict] = None):
58
+ if len(axes) != len(shape):
59
+ raise ValueError(f"axes {axes!r} do not match shape {tuple(shape)}")
60
+ #: place in the file's ``images`` (internal; ``f.images.index(image)``)
61
+ self._position = int(position)
62
+ self.axes = axes.lower()
63
+ self.shape = tuple(int(n) for n in shape)
64
+ self.dtype = np.dtype(dtype).newbyteorder("=")
65
+ self.name = name if name is not None else f"image{position}"
66
+ #: which image of the file: {"scene": .., "tile": .., "rotation": ..,
67
+ #: "view": .., "resolution": ..} -- only the indices the file has
68
+ #: ("resolution" only when opened with resolutions="all")
69
+ self.index = dict(index) if index is not None else {"scene": self._position}
70
+ self._resolutions: Optional[list] = None
71
+ self._metadata = None # backends set a _metadata.Metadata
72
+ #: None: any rectangle is read exactly. Otherwise the smallest block
73
+ #: the backend reads at once (e.g. a compressed tile or strip).
74
+ self.read_unit = None if read_unit is None else tuple(int(n) for n in read_unit)
75
+ self.stats = ReadStats()
76
+
77
+ @property
78
+ def ndim(self) -> int:
79
+ return len(self.shape)
80
+
81
+ @property
82
+ def metadata(self):
83
+ """Pixel and channel metadata (docs/METADATA.md): pixel sizes, stage
84
+ position, channels (name, colour, wavelengths), essential axis
85
+ values. What the file does not say is absent / None. Read on first
86
+ access; a resolution level's is level 0's with its pixel sizes scaled
87
+ by the shape ratio."""
88
+ if self._metadata is None:
89
+ from ._metadata import Metadata
90
+ base = self._resolutions[0] if self._resolutions else self
91
+ if base is not self:
92
+ factors = {a: base.shape[base.axes.index(a)] / self.shape[self.axes.index(a)]
93
+ for a in "xyz" if a in self.axes and a in base.axes}
94
+ self._metadata = base.metadata.scaled(factors)
95
+ else:
96
+ n = self.shape[self.axes.index("c")] if "c" in self.axes else 1
97
+ self._metadata = self._read_metadata() or Metadata.unknown(n)
98
+ return self._metadata
99
+
100
+ def _read_metadata(self):
101
+ """This image's ``_metadata.Metadata`` from the file, or None when the
102
+ backend reads none (backends override it)."""
103
+ return None
104
+
105
+ @property
106
+ def is_pyramidal(self) -> bool:
107
+ """Whether the file stores lower resolution levels of this image
108
+ (``resolutions`` holds more than the image itself)."""
109
+ return len(self.resolutions) > 1
110
+
111
+ @property
112
+ def resolutions(self) -> list:
113
+ """This image's pyramid, full resolution first: one image per
114
+ resolution level the file stores (just this image without levels)."""
115
+ return list(self._resolutions) if self._resolutions else [self]
116
+
117
+ @property
118
+ def nbytes(self) -> int:
119
+ return int(np.prod(self.shape)) * self.dtype.itemsize
120
+
121
+ def __getitem__(self, key) -> np.ndarray:
122
+ spans, squeeze, steps = normalise_index(key, self.shape)
123
+ block = tuple(slice(a, b) for a, b in spans)
124
+ requested = int(np.prod([b - a for a, b in spans]))
125
+ if requested == 0:
126
+ out = np.empty(tuple(b - a for a, b in spans), dtype=self.dtype)
127
+ pixels_read = 0
128
+ else:
129
+ out, pixels_read = self._read_block(block)
130
+ self.stats.add(requested, pixels_read)
131
+ if any(step != 1 for step in steps):
132
+ out = out[tuple(slice(None, None, step) for step in steps)]
133
+ if squeeze:
134
+ out = out.squeeze(axis=squeeze)
135
+ return out
136
+
137
+ def read(self, **selection) -> np.ndarray:
138
+ """Keyword form: ``image.read(z=3, y=slice(0, 512))``; omitted axes: all."""
139
+ unknown = set(selection) - set(self.axes)
140
+ if unknown:
141
+ raise KeyError(f"unknown axes {sorted(unknown)}; this image has {self.axes!r} "
142
+ "(open(path, axes='canonical') or image.as_axes('canonical') "
143
+ "adds the missing standard axes with size 1)")
144
+ return self[tuple(selection.get(ax, slice(None)) for ax in self.axes)]
145
+
146
+ def as_axes(self, axes: Optional[str] = None, *, samples: str = "keep",
147
+ squeeze=False) -> "Image":
148
+ """This image presented with *axes*: ``None`` keeps its order,
149
+ ``"canonical"`` is ``tczyx`` (+ ``s``), or explicit letters such as
150
+ ``"zyx"``. Missing axes are inserted with size 1, the others
151
+ reordered; reads stay exact. ``samples="channels"`` folds RGB
152
+ samples into the channels (C x S); ``squeeze=True`` drops size-1 axes
153
+ except y / x. An axis *axes* cannot hold is an error that names it.
154
+ The native image is ``.source`` of the result."""
155
+ from ._axes import AxesImage
156
+ return AxesImage(self, axes, samples=samples, squeeze=squeeze)
157
+
158
+ def squeeze(self, axes: Optional[str] = None) -> "Image":
159
+ """This image without its size-1 axes: all but ``y`` / ``x``, or just
160
+ those in *axes* (each must have size 1). They also leave ``axes``."""
161
+ from ._axes import AxesImage
162
+ return AxesImage(self, None, squeeze=axes or True)
163
+
164
+ def _read_block(self, block: tuple[slice, ...]) -> tuple[np.ndarray, int]:
165
+ """Read contiguous unit-step spans -> (array of exactly that shape,
166
+ number of pixels actually read or decoded to produce it)."""
167
+ raise NotImplementedError
168
+
169
+ def close(self) -> None:
170
+ """Release file handles; the image is unusable afterwards."""
171
+
172
+ def __repr__(self) -> str:
173
+ unit = "any rectangle" if self.read_unit is None else f"unit {self.read_unit}"
174
+ return (f"<micro_reader.Image {self.name!r} index={self.index} axes={self.axes!r} "
175
+ f"shape={self.shape} dtype={self.dtype} ({unit})>")
176
+
177
+
178
+ #: the indices that say which image of a file an image is, outermost first.
179
+ #: Arrays have only t c z y x (+ s); every other dimension is an index:
180
+ #: illumination (light-sheet sides), phase, block (CZI), emission / excitation
181
+ #: (spectral scans; the wavelength in the image's metadata), lifetime (FLIM
182
+ #: bins) and sequence (a dimension the file does not name; open(...,
183
+ #: rename={"sequence": "z"}) makes it an axis)
184
+ INDEX_NAMES = ("scene", "tile", "rotation", "view", "illumination", "phase", "block",
185
+ "emission", "excitation", "lifetime", "sequence", "resolution")
186
+ #: the indices that are not frames of the image but dimensions sampled at the
187
+ #: same points (what other readers keep as array axes)
188
+ SAMPLED_INDICES = ("illumination", "phase", "block", "emission", "excitation", "lifetime",
189
+ "sequence")
190
+
191
+
192
+ class File:
193
+ """An opened file: its images, the backend that reads them (``backend``:
194
+ "tiff", "czi", "nd2", "lif", "oir", "ims", "mrc", "raster") and the
195
+ format variant it is (``format``: e.g. "ome-tiff", "imagej", "lsm",
196
+ "nd2-legacy", "lof", "xlef", "png"). Every format opens as a File; a
197
+ backend's own class is an implementation detail."""
198
+
199
+ backend: str = "base"
200
+
201
+ def __init__(self, path: str, images: Sequence[Image]):
202
+ self.path = str(path)
203
+ self.images = list(images)
204
+ if not getattr(self, "format", None):
205
+ #: the format variant; the backend's name unless it reads several
206
+ self.format = self.backend
207
+ self._index_resolutions()
208
+
209
+ def _index_resolutions(self) -> None:
210
+ """``resolution`` is in every index when the file stores a pyramid of
211
+ any of its images (0 = full resolution, also for an image without
212
+ lower levels), and in none otherwise: an index holds the dimensions
213
+ the file has. The same with resolutions="base" or "all"."""
214
+ members, seen = [], set()
215
+ for image in self.images:
216
+ for member in image.resolutions:
217
+ if id(member) not in seen:
218
+ seen.add(id(member))
219
+ members.append(member)
220
+ pyramidal = any(image.is_pyramidal for image in self.images)
221
+ for member in members:
222
+ index = {k: v for k, v in member.index.items() if k != "resolution"}
223
+ if pyramidal:
224
+ index["resolution"] = member.index.get("resolution", 0)
225
+ member.index = index
226
+
227
+ @property
228
+ def indices(self) -> list:
229
+ """Every image's index, in the order of ``images``."""
230
+ return [image.index for image in self.images]
231
+
232
+ @property
233
+ def index_range(self) -> dict:
234
+ """The widest range of each index in the file: how many distinct
235
+ values it takes, e.g. ``{"scene": 2, "tile": 5}``. Not a total and
236
+ not a shape: with 3 tiles in scene 0 and 5 in scene 1, tile indices
237
+ run 0-2 and 0-4, so the range is 5 (not 8, and scene 0 has only 3).
238
+ Tile, rotation and view indices count from 0, so the values are
239
+ ``range(n)``; scene indices are the file's own (a CZI's may skip
240
+ numbers), so for scenes it is the number of distinct values."""
241
+ counts = {}
242
+ for name in INDEX_NAMES:
243
+ values = {image.index[name] for image in self.images if name in image.index}
244
+ if values:
245
+ counts[name] = len(values)
246
+ return counts
247
+
248
+ def select(self, **index) -> list:
249
+ """The images whose index matches, e.g. ``select(scene=1, view=0)``."""
250
+ unknown = set(index) - set(INDEX_NAMES)
251
+ if unknown:
252
+ raise KeyError(f"unknown indices {sorted(unknown)}; the indices are {INDEX_NAMES}")
253
+ return [image for image in self.images
254
+ if all(image.index.get(name) == value for name, value in index.items())]
255
+
256
+ def close(self) -> None:
257
+ for image in self.images:
258
+ image.close()
259
+
260
+ def __enter__(self) -> "File":
261
+ return self
262
+
263
+ def __exit__(self, *exc) -> None:
264
+ self.close()
265
+
266
+ def __repr__(self) -> str:
267
+ return (f"<micro_reader.File {self.path!r} backend={self.backend!r} "
268
+ f"format={self.format!r} images={len(self.images)}>")