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
micro_reader/__init__.py
ADDED
|
@@ -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)}>")
|