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,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])