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,439 @@
|
|
|
1
|
+
"""Olympus / Evident OIR (FluoView), own reader, numpy and stdlib only.
|
|
2
|
+
|
|
3
|
+
Structure: an ``OLYMPUSRAWFORMAT`` header points to a block index at the end
|
|
4
|
+
of the file. Blocks: a UID block names a plane --
|
|
5
|
+
``[l<n>][z<n>][t<n>][_<a>_<b>]_<channel guid>_<block>`` -- and is followed by
|
|
6
|
+
its PIXEL block (raw rows, little endian); FRAMEPROPERTIES XML gives width,
|
|
7
|
+
height and bytes per sample; METADATA XML (``lsmimage``) the channel order
|
|
8
|
+
and the acquisition axes. A plane is the concatenation of its pixel blocks
|
|
9
|
+
in index order, possibly continued in companion files ``<name>_00001``,
|
|
10
|
+
``<name>_00002``, ...
|
|
11
|
+
|
|
12
|
+
Reading is exact: any rows / columns of a plane are read straight from the
|
|
13
|
+
blocks holding those bytes. One image, axes ``tczyx``; planar RGB appears as
|
|
14
|
+
channels; a lambda (spectral) scan is one image per emission band (index
|
|
15
|
+
``emission``, its wavelength in ``metadata.index_values``).
|
|
16
|
+
Not yet: line scans, POIR archives.
|
|
17
|
+
|
|
18
|
+
The block-index parsing is ported, reduced, from oirfile
|
|
19
|
+
(https://github.com/cgohlke/oirfile):
|
|
20
|
+
|
|
21
|
+
Copyright (c) 2025-2026, Christoph Gohlke
|
|
22
|
+
All rights reserved.
|
|
23
|
+
|
|
24
|
+
Redistribution and use in source and binary forms, with or without
|
|
25
|
+
modification, are permitted provided that the following conditions are met:
|
|
26
|
+
|
|
27
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
28
|
+
this list of conditions and the following disclaimer.
|
|
29
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
30
|
+
notice, this list of conditions and the following disclaimer in the
|
|
31
|
+
documentation and/or other materials provided with the distribution.
|
|
32
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
33
|
+
contributors may be used to endorse or promote products derived from
|
|
34
|
+
this software without specific prior written permission.
|
|
35
|
+
|
|
36
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
|
37
|
+
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
|
38
|
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
39
|
+
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
|
40
|
+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
41
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
42
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
43
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
44
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
45
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
46
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import bisect
|
|
51
|
+
import copy
|
|
52
|
+
import builtins
|
|
53
|
+
import itertools
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import struct
|
|
57
|
+
import sys
|
|
58
|
+
from xml.etree import ElementTree
|
|
59
|
+
|
|
60
|
+
import numpy as np
|
|
61
|
+
|
|
62
|
+
from .. import _pool
|
|
63
|
+
from .._base import File, Image
|
|
64
|
+
from .._errors import CorruptFileError, UnsupportedFormatError
|
|
65
|
+
from .._metadata import Channel, Metadata, color_hex, quantity
|
|
66
|
+
from .._io import ThreadHandles
|
|
67
|
+
|
|
68
|
+
NAME = "oir"
|
|
69
|
+
MAGIC = b"OLYMPUSRAWFORMAT"
|
|
70
|
+
RAW_TASK_BYTES = 8 << 20 # planes are read in row pieces of this size
|
|
71
|
+
BLOCK_METADATA, BLOCK_FRAMEPROPERTIES, BLOCK_UID, BLOCK_PIXEL = 0, 1, 3, 4
|
|
72
|
+
UID_PATTERN = re.compile(
|
|
73
|
+
r"(?:l(\d+))?(?:z(\d+))?(?:t(\d+))?(?:_(\d+)_(\d+))?_([0-9a-f-]+)_(\d+)$")
|
|
74
|
+
NATIVE_LITTLE = sys.byteorder == "little"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: a frame's id: l<lambda>z<z>t<t> (each part where the image has the axis)
|
|
78
|
+
FRAME_ID = re.compile(r"(?:l(\d+))?(?:z(\d+))?(?:t(\d+))?(?:_|$)")
|
|
79
|
+
FRAME_AXIS = re.compile(r"<commonframe:axisType>(\w+)</commonframe:axisType>\s*"
|
|
80
|
+
r"<commonframe:position>([^<]*)</commonframe:position>")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _frame_positions(frames: list) -> dict:
|
|
84
|
+
"""frame id -> {axis type: position} from the frame-properties XMLs."""
|
|
85
|
+
out = {}
|
|
86
|
+
for text in frames:
|
|
87
|
+
match = re.search(r'frameProperties[^>]*\sid="([^"]+)"', text)
|
|
88
|
+
if not match:
|
|
89
|
+
continue
|
|
90
|
+
values = {}
|
|
91
|
+
for axis, value in FRAME_AXIS.findall(text):
|
|
92
|
+
try:
|
|
93
|
+
values[axis] = float(value)
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
out.setdefault(match.group(1), values)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: a channel guid, then its lookup table
|
|
101
|
+
LUT_PATTERN = re.compile(rb"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
|
|
102
|
+
rb"[^<]{0,64}<\?xml[^>]*>\s*(<lut:LUT.*?</lut:LUT>)", re.S)
|
|
103
|
+
LUT_CONTRAST = re.compile(rb"<lut:contrast>([^<]*)</lut:contrast>")
|
|
104
|
+
#: OIR pixelUnit -> unit
|
|
105
|
+
OIR_UNITS = {"MICRO_METER": "micrometer", "NANO_METER": "nanometer", "MILLI_METER": "millimeter",
|
|
106
|
+
"METER": "meter", "PICO_METER": "picometer"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sniff(head: bytes, path: str) -> bool:
|
|
110
|
+
return head[:16] == MAGIC
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _local(tag: str) -> str:
|
|
114
|
+
return tag.rsplit("}", 1)[-1]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _scan(path: str, file_index: int):
|
|
118
|
+
"""Blocks of one OIR file -> ([(uid, file_index, pixel offset, length)],
|
|
119
|
+
[frame-properties XML], [lsmimage XML], {channel guid: colour}). A
|
|
120
|
+
channel's colour is its lookup table's (``lut:LUT``, after the guid):
|
|
121
|
+
the contrast of its red, green and blue components."""
|
|
122
|
+
pairs, frames, lsm, luts = [], [], [], {}
|
|
123
|
+
with builtins.open(path, "rb") as fh:
|
|
124
|
+
head = fh.read(48)
|
|
125
|
+
if len(head) < 48 or head[:16] != MAGIC:
|
|
126
|
+
raise CorruptFileError(f"{path!r} is not an OIR file")
|
|
127
|
+
file_size, index_pos = struct.unpack("<QQ", head[32:48])
|
|
128
|
+
fh.seek(index_pos)
|
|
129
|
+
index = fh.read(file_size - index_pos)
|
|
130
|
+
offsets = []
|
|
131
|
+
for (offset,) in struct.iter_unpack("<Q", index[4:4 + (len(index) - 4) // 8 * 8]):
|
|
132
|
+
if offset == 0 or offset > file_size:
|
|
133
|
+
break
|
|
134
|
+
offsets.append(offset)
|
|
135
|
+
|
|
136
|
+
def read_at(offset, size):
|
|
137
|
+
fh.seek(offset)
|
|
138
|
+
return fh.read(size)
|
|
139
|
+
|
|
140
|
+
i = 0
|
|
141
|
+
while i < len(offsets):
|
|
142
|
+
header = read_at(offsets[i], 20)
|
|
143
|
+
if len(header) < 8:
|
|
144
|
+
break
|
|
145
|
+
length, kind = struct.unpack("<II", header[:8])
|
|
146
|
+
if kind == BLOCK_UID and len(header) == 20 and i + 1 < len(offsets):
|
|
147
|
+
uid_len, = struct.unpack("<I", header[16:20])
|
|
148
|
+
uid = read_at(offsets[i] + 20, uid_len).decode("ascii")
|
|
149
|
+
plen, ptype = struct.unpack("<II", read_at(offsets[i + 1], 8))
|
|
150
|
+
if ptype == BLOCK_PIXEL:
|
|
151
|
+
pairs.append((uid, file_index, offsets[i + 1] + 8, plen))
|
|
152
|
+
i += 2
|
|
153
|
+
continue
|
|
154
|
+
elif kind == BLOCK_FRAMEPROPERTIES:
|
|
155
|
+
content = read_at(offsets[i] + 8, length)
|
|
156
|
+
start = content.find(b"<?xml")
|
|
157
|
+
if start >= 0:
|
|
158
|
+
frames.append(content[start:].decode("utf-8"))
|
|
159
|
+
elif kind == BLOCK_METADATA:
|
|
160
|
+
content = read_at(offsets[i] + 8, length)
|
|
161
|
+
for piece in content.split(b"<?xml")[1:]:
|
|
162
|
+
xml = b"<?xml" + piece[:piece.rfind(b">") + 1]
|
|
163
|
+
if b"lsmimage:" in xml[:200]:
|
|
164
|
+
lsm.append(xml.decode("utf-8", "replace"))
|
|
165
|
+
for match in LUT_PATTERN.finditer(content):
|
|
166
|
+
guid, body = match.group(1).decode("ascii"), match.group(2)
|
|
167
|
+
rgb = [LUT_CONTRAST.search(body, body.find(b"<lut:" + c + b">"))
|
|
168
|
+
for c in (b"red", b"green", b"blue")]
|
|
169
|
+
if all(rgb):
|
|
170
|
+
luts.setdefault(guid, tuple(min(1.0, max(0.0, float(m.group(1))))
|
|
171
|
+
for m in rgb))
|
|
172
|
+
i += 1
|
|
173
|
+
return pairs, frames, lsm, luts
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _OirImage(Image):
|
|
177
|
+
"""An image of one OIR file (with its companion files): axes 'tczyx'; a
|
|
178
|
+
lambda (spectral) scan has one image per emission band (``at_lambda``)."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, path: str):
|
|
181
|
+
pairs, frames, lsm, self._luts = _scan(path, 0)
|
|
182
|
+
if not frames or not pairs:
|
|
183
|
+
raise UnsupportedFormatError(f"{path!r}: OIR file without image data")
|
|
184
|
+
# companion files: <name>_00001, <name>_00002, ... (no extension)
|
|
185
|
+
self._paths = [path]
|
|
186
|
+
prefix = os.path.splitext(path)[0]
|
|
187
|
+
while os.path.exists(f"{prefix}_{len(self._paths):05d}"):
|
|
188
|
+
more, _, _, _ = _scan(f"{prefix}_{len(self._paths):05d}", len(self._paths))
|
|
189
|
+
self._paths.append(f"{prefix}_{len(self._paths):05d}")
|
|
190
|
+
pairs.extend(more)
|
|
191
|
+
|
|
192
|
+
frame = ElementTree.fromstring(frames[0])
|
|
193
|
+
found = {}
|
|
194
|
+
for elem in frame.iter():
|
|
195
|
+
if elem.text and _local(elem.tag) in ("width", "height", "depth", "colorType"):
|
|
196
|
+
found.setdefault(_local(elem.tag), elem.text.strip())
|
|
197
|
+
width, height = int(found.get("width", 0)), int(found.get("height", 0))
|
|
198
|
+
depth = int(found.get("depth", 0))
|
|
199
|
+
if not (width and height and depth in (1, 2, 4)):
|
|
200
|
+
raise UnsupportedFormatError(f"{path!r}: OIR frame {found}")
|
|
201
|
+
self._file_dtype = np.dtype({1: "<u1", 2: "<u2", 4: "<f4"}[depth])
|
|
202
|
+
|
|
203
|
+
channel_order, axes = self._lsm_info(lsm)
|
|
204
|
+
self._lsm = lsm[0] if lsm else None
|
|
205
|
+
#: frame id (``l001z001t002``) -> {axis type: position}: TIMELAPSE in
|
|
206
|
+
#: ms since the start, LAMBDA in nm, ZSTACK in um
|
|
207
|
+
self._frame_positions = _frame_positions(frames)
|
|
208
|
+
|
|
209
|
+
planes: dict = {}
|
|
210
|
+
for uid, file_index, offset, length in pairs:
|
|
211
|
+
match = UID_PATTERN.match(uid)
|
|
212
|
+
if match is None: # e.g. REF_ reference images
|
|
213
|
+
continue
|
|
214
|
+
l_str, z_str, t_str, _a, _b, guid, _blk = match.groups()
|
|
215
|
+
key = (int(l_str or 0), int(t_str or 0), int(z_str or 0), guid)
|
|
216
|
+
planes.setdefault(key, []).append((file_index, offset, length))
|
|
217
|
+
|
|
218
|
+
lambdas = sorted({lam for lam, _, _, _ in planes})
|
|
219
|
+
times = sorted({t for _, t, _, _ in planes})
|
|
220
|
+
zs = sorted({z for _, _, z, _ in planes})
|
|
221
|
+
guids = {g for _, _, _, g in planes}
|
|
222
|
+
channels = [g for g in channel_order if g in guids] + sorted(guids - set(channel_order))
|
|
223
|
+
self._row_bytes = width * depth
|
|
224
|
+
plane_bytes = height * self._row_bytes
|
|
225
|
+
if any(sum(n for _, _, n in blocks) > plane_bytes for blocks in planes.values()):
|
|
226
|
+
raise UnsupportedFormatError(
|
|
227
|
+
f"{path!r}: OIR planes longer than one frame (line scan / concatenated scan)")
|
|
228
|
+
if "TIMELAPSE" in axes and len(times) == 1 and \
|
|
229
|
+
int(axes["TIMELAPSE"].get("maxSize", -1)) == height:
|
|
230
|
+
raise UnsupportedFormatError(f"{path!r}: OIR line scan (kymograph)")
|
|
231
|
+
|
|
232
|
+
# plane (l, t, c, z) -> blocks and their start within the plane's bytes
|
|
233
|
+
self._planes = {}
|
|
234
|
+
for (lam, t, z, guid), blocks in planes.items():
|
|
235
|
+
starts = list(itertools.accumulate([0] + [n for _, _, n in blocks]))
|
|
236
|
+
self._planes[(lambdas.index(lam), times.index(t), channels.index(guid),
|
|
237
|
+
zs.index(z))] = (blocks, starts)
|
|
238
|
+
self._handles = [ThreadHandles(p) for p in self._paths]
|
|
239
|
+
self._channel_ids = channels
|
|
240
|
+
self._ids = (lambdas, times, zs) # file indices per axis
|
|
241
|
+
self._closed = False
|
|
242
|
+
#: which emission band of a lambda scan this image is (0: the only one)
|
|
243
|
+
self._lam = 0
|
|
244
|
+
shape = (len(times), len(channels), len(zs), height, width)
|
|
245
|
+
super().__init__(0, "tczyx", shape, self._file_dtype, name=os.path.basename(path))
|
|
246
|
+
|
|
247
|
+
def at_lambda(self, k: int) -> "_OirImage":
|
|
248
|
+
"""This file's image of emission band *k* (index ``emission``), sharing
|
|
249
|
+
the parsed structure and file handles."""
|
|
250
|
+
image = copy.copy(self)
|
|
251
|
+
image._lam, image._position = k, k
|
|
252
|
+
image.index = {"scene": 0, "emission": k}
|
|
253
|
+
image._metadata = None
|
|
254
|
+
image.stats = type(self.stats)()
|
|
255
|
+
return image
|
|
256
|
+
|
|
257
|
+
def _read_metadata(self):
|
|
258
|
+
"""From the lsmimage XML: per channel (``imageInfo``) the pixel
|
|
259
|
+
``length`` x / y in its ``pixelUnit``, the name, the detection band
|
|
260
|
+
(emission: its middle) and the laser (excitation: the wavelength in
|
|
261
|
+
its name, e.g. LD405), the LUT colour; z: the step of the enabled Z
|
|
262
|
+
stack. From the frames' own axis positions: the time step (ms) and
|
|
263
|
+
the wavelength of each lambda index (D5). Stage x / y: not in OIR
|
|
264
|
+
files (a tiling's layout is in the experiment's .omp2info)."""
|
|
265
|
+
if not self._lsm:
|
|
266
|
+
return None
|
|
267
|
+
root = ElementTree.fromstring(self._lsm)
|
|
268
|
+
by_id = {}
|
|
269
|
+
for elem in root.iter():
|
|
270
|
+
if _local(elem.tag) == "channel" and elem.get("id") and elem.get("id") not in by_id:
|
|
271
|
+
if any(_local(c.tag) == "length" for c in elem):
|
|
272
|
+
by_id[elem.get("id")] = elem
|
|
273
|
+
sizes, channels = {}, []
|
|
274
|
+
for guid in self._channel_ids:
|
|
275
|
+
elem = by_id.get(guid)
|
|
276
|
+
if elem is None:
|
|
277
|
+
channels.append(Channel())
|
|
278
|
+
continue
|
|
279
|
+
parts = {_local(c.tag): c for c in elem}
|
|
280
|
+
if not sizes and "length" in parts:
|
|
281
|
+
units = {_local(u.tag): (u.text or "").strip() for u in parts.get("pixelUnit", [])}
|
|
282
|
+
for v in parts["length"]:
|
|
283
|
+
axis = _local(v.tag)
|
|
284
|
+
if axis in ("x", "y"):
|
|
285
|
+
q = quantity(v.text, OIR_UNITS.get(units.get(axis), units.get(axis)))
|
|
286
|
+
if q:
|
|
287
|
+
sizes[axis] = q
|
|
288
|
+
band = [float(w.text) for w in elem.iter()
|
|
289
|
+
if _local(w.tag) in ("startWavelength", "endWavelength") and w.text]
|
|
290
|
+
laser = parts.get("laserDataId")
|
|
291
|
+
match = re.match(r"\D*(\d{3,4})", laser.text or "") if laser is not None else None
|
|
292
|
+
name = parts.get("name")
|
|
293
|
+
rgb = self._luts.get(guid)
|
|
294
|
+
channels.append(Channel(
|
|
295
|
+
name=(name.text or "").strip() or None if name is not None else None,
|
|
296
|
+
color=color_hex(rgb) if rgb and rgb != (1.0, 1.0, 1.0) else None,
|
|
297
|
+
emission_wavelength=quantity(sum(band[:2]) / 2, "nanometer") if len(band) >= 2 else None,
|
|
298
|
+
excitation_wavelength=quantity(match.group(1), "nanometer") if match else None))
|
|
299
|
+
lambdas, times, zs = self._ids
|
|
300
|
+
lam = lambdas[self._lam]
|
|
301
|
+
index_values = {}
|
|
302
|
+
if len(lambdas) > 1:
|
|
303
|
+
value = self._frame_position(lam, times[0], zs[0], "LAMBDA")
|
|
304
|
+
q = quantity(value, "nanometer") if value is not None else None
|
|
305
|
+
if q:
|
|
306
|
+
index_values["emission"] = q
|
|
307
|
+
if len(times) > 1:
|
|
308
|
+
# the frames' own times (ms since the start; their timestamps agree),
|
|
309
|
+
# not the time-lapse step (the interval asked for, in s, 0 for
|
|
310
|
+
# free run) nor seriesInterval (the time one series takes)
|
|
311
|
+
t0 = self._frame_position(lam, times[0], zs[0], "TIMELAPSE")
|
|
312
|
+
t1 = self._frame_position(lam, times[1], zs[0], "TIMELAPSE")
|
|
313
|
+
if t0 is not None and t1 is not None:
|
|
314
|
+
q = quantity(t1 - t0, "millisecond")
|
|
315
|
+
if q:
|
|
316
|
+
sizes["t"] = q
|
|
317
|
+
if self.shape[self.axes.index("z")] > 1:
|
|
318
|
+
for axis in root.iter():
|
|
319
|
+
if _local(axis.tag) == "axis" and axis.get("enable") == "true" and axis.get("paramEnable", "true") == "true":
|
|
320
|
+
kids = {_local(c.tag): (c.text or "").strip() for c in axis}
|
|
321
|
+
if kids.get("axis") == "ZSTACK":
|
|
322
|
+
q = quantity(abs(float(kids.get("step") or 0)), "micrometer")
|
|
323
|
+
if q:
|
|
324
|
+
sizes["z"] = q
|
|
325
|
+
break
|
|
326
|
+
return Metadata(pixel_sizes=sizes, channels=tuple(channels), index_values=index_values)
|
|
327
|
+
|
|
328
|
+
def _frame_position(self, lam: int, t: int, z: int, axis: str):
|
|
329
|
+
"""Position of frame (lambda, t, z) along *axis*, from its frame
|
|
330
|
+
properties; None if not recorded."""
|
|
331
|
+
for fid, values in self._frame_positions.items():
|
|
332
|
+
m = FRAME_ID.match(fid)
|
|
333
|
+
if m and (int(m.group(1) or 0), int(m.group(3) or 0), int(m.group(2) or 0)) == (lam, t, z) and axis in values:
|
|
334
|
+
return values[axis]
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _lsm_info(lsm):
|
|
339
|
+
"""(channel guids in acquisition order, {axis type: info}) from lsmimage."""
|
|
340
|
+
if not lsm:
|
|
341
|
+
return [], {}
|
|
342
|
+
root = ElementTree.fromstring(lsm[0])
|
|
343
|
+
channels, axes = {}, {}
|
|
344
|
+
for elem in root.iter():
|
|
345
|
+
tag = _local(elem.tag)
|
|
346
|
+
if tag == "channel" and elem.get("id"):
|
|
347
|
+
channels.setdefault(elem.get("id"), int(elem.get("order") or 0))
|
|
348
|
+
elif tag == "axis" and elem.get("enable") == "true":
|
|
349
|
+
info, kind = {}, ""
|
|
350
|
+
for child in elem:
|
|
351
|
+
ctag = _local(child.tag)
|
|
352
|
+
if ctag == "axis" and child.text:
|
|
353
|
+
kind = child.text.strip()
|
|
354
|
+
elif ctag == "maxSize" and child.text:
|
|
355
|
+
info["maxSize"] = int(child.text.strip())
|
|
356
|
+
if kind:
|
|
357
|
+
axes[kind] = info
|
|
358
|
+
order = sorted(channels, key=lambda g: channels[g]) # stable: ties keep file order
|
|
359
|
+
return order, axes
|
|
360
|
+
|
|
361
|
+
# -- reading ---------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _read_range(self, blocks, starts, start: int, dest: memoryview) -> int:
|
|
364
|
+
"""Bytes [start, start + len(dest)) of a plane into *dest*; bytes past
|
|
365
|
+
the plane's data (an unfinished acquisition) become zeros."""
|
|
366
|
+
pos, end = start, start + len(dest)
|
|
367
|
+
i = bisect.bisect_right(starts, pos) - 1
|
|
368
|
+
while pos < end and i < len(blocks):
|
|
369
|
+
file_index, offset, length = blocks[i]
|
|
370
|
+
take = min(end, starts[i] + length) - pos
|
|
371
|
+
fh = self._handles[file_index].get()
|
|
372
|
+
fh.seek(offset + pos - starts[i])
|
|
373
|
+
if fh.readinto(dest[pos - start:pos - start + take]) != take:
|
|
374
|
+
raise CorruptFileError(f"{self._paths[file_index]!r}: pixel block ends early")
|
|
375
|
+
pos += take
|
|
376
|
+
i += 1
|
|
377
|
+
if pos < end:
|
|
378
|
+
dest[pos - start:] = bytes(end - pos)
|
|
379
|
+
return end - start
|
|
380
|
+
|
|
381
|
+
def _read_rows(self, plane, y0, y1, xs, dst) -> int:
|
|
382
|
+
"""Rows y0:y1, columns xs of a plane into *dst* (rows, columns)."""
|
|
383
|
+
blocks, starts = plane
|
|
384
|
+
item, row = self._file_dtype.itemsize, self._row_bytes
|
|
385
|
+
direct = NATIVE_LITTLE or item == 1
|
|
386
|
+
if xs.start == 0 and xs.stop * item == row:
|
|
387
|
+
if direct and dst.flags.c_contiguous:
|
|
388
|
+
self._read_range(blocks, starts, y0 * row, memoryview(dst).cast("B"))
|
|
389
|
+
else:
|
|
390
|
+
buf = np.empty((y1 - y0, xs.stop), self._file_dtype)
|
|
391
|
+
self._read_range(blocks, starts, y0 * row, memoryview(buf).cast("B"))
|
|
392
|
+
dst[...] = buf
|
|
393
|
+
return dst.size
|
|
394
|
+
buf = None if direct else np.empty(xs.stop - xs.start, self._file_dtype)
|
|
395
|
+
for i, y in enumerate(range(y0, y1)):
|
|
396
|
+
target = dst[i] if direct else buf
|
|
397
|
+
self._read_range(blocks, starts, y * row + xs.start * item,
|
|
398
|
+
memoryview(target).cast("B"))
|
|
399
|
+
if not direct:
|
|
400
|
+
dst[i] = buf
|
|
401
|
+
return dst.size
|
|
402
|
+
|
|
403
|
+
def _read_block(self, block):
|
|
404
|
+
if self._closed:
|
|
405
|
+
raise ValueError("this OIR image has been closed: its file was closed "
|
|
406
|
+
"(File.close() or the end of a with block)")
|
|
407
|
+
ts, cs, zs, ys, xs = block
|
|
408
|
+
out = np.empty(tuple(s.stop - s.start for s in block), dtype=self.dtype)
|
|
409
|
+
per_task = max(1, RAW_TASK_BYTES // self._row_bytes)
|
|
410
|
+
tasks = []
|
|
411
|
+
for t, c, z in itertools.product(range(ts.start, ts.stop), range(cs.start, cs.stop),
|
|
412
|
+
range(zs.start, zs.stop)):
|
|
413
|
+
dst = out[t - ts.start, c - cs.start, z - zs.start]
|
|
414
|
+
plane = self._planes.get((self._lam, t, c, z))
|
|
415
|
+
if plane is None:
|
|
416
|
+
dst[...] = 0 # never acquired
|
|
417
|
+
continue
|
|
418
|
+
for y0 in range(ys.start, ys.stop, per_task):
|
|
419
|
+
y1 = min(ys.stop, y0 + per_task)
|
|
420
|
+
part = dst[y0 - ys.start:y1 - ys.start]
|
|
421
|
+
tasks.append(lambda a=(plane, y0, y1, xs, part): self._read_rows(*a))
|
|
422
|
+
return out, _pool.run(tasks)
|
|
423
|
+
|
|
424
|
+
def close(self) -> None:
|
|
425
|
+
self._closed = True
|
|
426
|
+
for handles in self._handles:
|
|
427
|
+
handles.close()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class _OirFile(File):
|
|
431
|
+
backend = NAME
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def open(path: str, tiles: str = "separate", resolutions: str = "base") -> _OirFile: # noqa: A001 - backend protocol name
|
|
435
|
+
image = _OirImage(path)
|
|
436
|
+
lambdas = image._ids[0]
|
|
437
|
+
if len(lambdas) > 1: # a lambda scan: one image per emission band
|
|
438
|
+
return _OirFile(path, [image.at_lambda(k) for k in range(len(lambdas))])
|
|
439
|
+
return _OirFile(path, [image])
|