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,713 @@
1
+ """Leica image files: LIF, LOF, LIFEXT, XLIF, XLEF and XLCF, own reader.
2
+
3
+ The LIF container and its XML image descriptions follow Leica's description
4
+ of the format. The structure of the other flavours follows liffile
5
+ (Christoph Gohlke, BSD-3-Clause; notice below). No code from readlif
6
+ (GPL-3) or Bio-Formats (GPL-2).
7
+
8
+ - **LIF**: a header block -- int32 0x70, int32 length, byte 0x2A, int32
9
+ character count, the XML description in UTF-16 -- then memory blocks:
10
+ int32 0x70, int32 length, byte 0x2A, the data size (int32 in version 1,
11
+ int64 in version 2), byte 0x2A, int32 character count, the block's ID in
12
+ UTF-16, then the data.
13
+ - **LOF** (one image): a header block whose text is ``LMS_Object_File``,
14
+ two version fields (byte 0x2A, int32 each), the single memory block (byte
15
+ 0x2A, int64 size, data), then the XML header block at the end.
16
+ - **LIFEXT** (extra image data of a LIF): a LIF-like file whose XML root is
17
+ ``LMSDataContainerEnhancedHeader``; images sit under ``ChildrenOf``
18
+ elements and are named after that element's ``MemoryBlockID``.
19
+ - **XLIF** (LAS X, one image): an XML text file; its memory block is
20
+ assembled from frame files -- ``.lof``, ``.tif`` (also OME / Aivia,
21
+ multi-page), ``.jpg``, ``.png``, ``.bmp`` -- each placed at its byte
22
+ ``Offset`` in the block.
23
+ - **XLEF** (experiment) and **XLCF** (collection): XML text files that
24
+ reference other files (``Children/Reference File=...``, relative paths);
25
+ their images are those of the referenced files, recursively. XLCF image
26
+ names get the collection's name in front, XLEF ones do not (as liffile).
27
+
28
+ An ``Element`` with ``Data/Image`` is an image: its ``ImageDescription``
29
+ lists the channels (data type, bit resolution, byte offset ``BytesInc``)
30
+ and the dimensions (``DimID``, ``NumberOfElements``, byte stride
31
+ ``BytesInc``), which place every sample in the memory block. Pixels are
32
+ little-endian.
33
+
34
+ Images (docs/AXES.md): the Leica images in document order (nested analysis
35
+ results included), as readlif and Bio-Formats list them, are the scenes;
36
+ each of their tiles, rotations and emission / excitation wavelengths
37
+ (lambda scans) is its own image, indexed by ``{"scene": n, "tile": m,
38
+ "rotation": r, "emission": l, "excitation": e}`` (an index only where some
39
+ image of the file has more than one; the wavelength of an emission /
40
+ excitation index in the image's ``metadata.index_values``). Axes: ``t c z
41
+ y x``, and ``s`` for interleaved RGB -- ordered R, G, B by the
42
+ ChannelTags for raw blocks (LAS stores B, G, R), as decoded for image-file
43
+ frames. Every
44
+ request reads only the rows it needs; a row range is one contiguous read
45
+ unless the bytes it would skip cost more than separate reads.
46
+
47
+ Data type 1 is floating point: 32-bit float, and 16-bit **half** float
48
+ (LAS X FLIM results -- lifetimes, phasor coordinates). readlif and
49
+ Bio-Formats return the half floats' raw bits as uint16; micro-reader (like
50
+ liffile) returns float16 values.
51
+
52
+ liffile notice (structure of LOF / LIFEXT / XLIF / XLEF / XLCF):
53
+
54
+ Copyright (c) 2023-2026, Christoph Gohlke. All rights reserved.
55
+ Redistribution and use in source and binary forms, with or without
56
+ modification, are permitted under the BSD-3-Clause licence: source
57
+ redistributions must retain this notice; binary redistributions must
58
+ reproduce it in the documentation; the copyright holder's name may not
59
+ be used to endorse derived products without permission. THIS SOFTWARE
60
+ IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
61
+ """
62
+ from __future__ import annotations
63
+
64
+ import builtins
65
+ import itertools
66
+ import os
67
+ import warnings
68
+ import re
69
+ import struct
70
+ import threading
71
+ import xml.etree.ElementTree as ET
72
+ from urllib.parse import unquote
73
+
74
+ import numpy as np
75
+
76
+ from .. import _codecs, _pool
77
+ from .._base import File, Image
78
+ from .._errors import CorruptFileError, UnsupportedFormatError
79
+ from .._io import DecodeCache, ThreadHandles
80
+ from . import raster
81
+
82
+ NAME = "lif"
83
+ MAGIC, STAR = 0x70, 0x2A
84
+ LOF_TEXT = "LMS_Object_File"
85
+ EXT_ROOT = "LMSDataContainerEnhancedHeader"
86
+ RAW_TASK_BYTES = 8 << 20 # planes are read in row bands of about this size
87
+ SEPARATE_READ_BYTES = 32 << 10 # bytes worth skipping rather than issuing one more read
88
+ FRAME_CACHE_BYTES = 128 << 20 # decoded XLIF frame files kept
89
+
90
+ #: DimID -> axis; X and Y are 1 and 2
91
+ DIM_AXES = {3: "z", 4: "t"}
92
+ #: DimID -> image index
93
+ DIM_KEYS = {10: "tile", 6: "rotation", 5: "emission", 9: "excitation"}
94
+ DIM_NAMES = {1: "X", 2: "Y", 3: "Z", 4: "T", 5: "lambda", 6: "rotation", 7: "XT slice",
95
+ 8: "T slice", 9: "excitation wavelength", 10: "tile", 11: "loop"}
96
+ #: axis order of an image (before y, x and s)
97
+ PLANE_AXES = "tcz"
98
+ #: image indices, in index order
99
+ OTHER_INDICES = ("tile", "rotation", "emission", "excitation")
100
+ #: first bytes of an XML text file -> its encoding
101
+ XML_CODECS = ((b"\xef\xbb\xbf", "utf-8-sig"), (b"\xff\xfe", "utf-16"), (b"\xfe\xff", "utf-16"),
102
+ (b"<\x00", "utf-16-le"), (b"\x00<", "utf-16-be"), (b"<", "utf-8"))
103
+ IMAGE_FRAMES = (".jpg", ".jpeg", ".png", ".bmp")
104
+ TIFF_FRAMES = (".tif", ".tiff")
105
+
106
+
107
+ def _xml_codec(head: bytes):
108
+ for prefix, codec in XML_CODECS:
109
+ if head.startswith(prefix):
110
+ return codec
111
+ return None
112
+
113
+
114
+ def sniff(head: bytes, path: str) -> bool:
115
+ if len(head) >= 13:
116
+ magic, _length, star = struct.unpack_from("<iiB", head)
117
+ if magic == MAGIC and star == STAR:
118
+ return True # LIF, LOF, LIFEXT
119
+ codec = _xml_codec(head) # XLIF, XLEF, XLCF
120
+ return codec is not None and "LMSDataContainerHeader" in head.decode(codec, errors="ignore")
121
+
122
+
123
+ def _dtype(data_type: int, resolution: int, where: str) -> np.dtype:
124
+ """Sample type from a channel's DataType (0 integer, 1 float) and bit
125
+ Resolution (12-bit data sits in 16-bit samples)."""
126
+ if data_type == 0:
127
+ for bits, code in ((8, "u1"), (16, "<u2"), (32, "<u4")):
128
+ if resolution <= bits:
129
+ return np.dtype(code)
130
+ elif data_type == 1:
131
+ code = {16: "<f2", 32: "<f4", 64: "<f8"}.get(resolution)
132
+ if code:
133
+ return np.dtype(code)
134
+ raise UnsupportedFormatError(f"{where}: LIF data type {data_type} at {resolution} bits")
135
+
136
+
137
+ def _memory_blocks(fh, version: int, path: str) -> dict:
138
+ """ID -> (data offset, size) of every memory block, walking the headers."""
139
+ blocks = {}
140
+ size_fmt = "<q" if version >= 2 else "<i"
141
+ size_len = struct.calcsize(size_fmt)
142
+ end = os.fstat(fh.fileno()).st_size
143
+ while True:
144
+ at = fh.tell()
145
+ head = fh.read(9)
146
+ if not head:
147
+ return blocks
148
+ if len(head) < 9:
149
+ raise CorruptFileError(f"{path!r}: truncated memory block header at byte {at}")
150
+ magic, _length, star = struct.unpack("<iiB", head)
151
+ if magic != MAGIC or star != STAR:
152
+ raise CorruptFileError(f"{path!r}: no memory block at byte {at}")
153
+ size, = struct.unpack(size_fmt, fh.read(size_len))
154
+ star, nchars = struct.unpack("<Bi", fh.read(5))
155
+ if star != STAR or not 0 <= nchars < 1 << 16:
156
+ raise CorruptFileError(f"{path!r}: bad memory block header at byte {at}")
157
+ name = fh.read(2 * nchars).decode("utf-16-le")
158
+ offset = fh.tell()
159
+ if offset + size > end:
160
+ raise CorruptFileError(f"{path!r}: memory block {name!r} ends beyond the file "
161
+ "(truncated file?)")
162
+ blocks[name] = (offset, size)
163
+ fh.seek(size, 1)
164
+
165
+
166
+ def _resolve(dirname: str, ref: str, where: str) -> str:
167
+ """A file referenced from an XLIF / XLEF / XLCF: relative, URL-quoted,
168
+ possibly with backslashes and another letter case (written on Windows)."""
169
+ rel = os.path.normpath(unquote(ref).replace("\\", "/"))
170
+ path = os.path.join(dirname, rel)
171
+ if os.path.exists(path):
172
+ return path
173
+ current = dirname
174
+ for part in rel.split(os.sep):
175
+ try:
176
+ match = [e for e in os.listdir(current or ".") if e.lower() == part.lower()]
177
+ except OSError:
178
+ match = []
179
+ if not match:
180
+ raise CorruptFileError(f"{where}: referenced file {ref!r} not found")
181
+ current = os.path.join(current, match[0])
182
+ return current
183
+
184
+
185
+ # -- memory blocks ------------------------------------------------------------------
186
+
187
+ class _FileBlock:
188
+ """A memory block stored in a file (LIF, LOF, LIFEXT): positioned reads."""
189
+
190
+ by_tags = True # raw samples: colour order from the ChannelTags
191
+
192
+ def __init__(self, handles: ThreadHandles, offset: int, size: int):
193
+ self.handles, self.offset, self.size = handles, offset, size
194
+
195
+ def readinto(self, at: int, buf: np.ndarray) -> None:
196
+ fh = self.handles.get()
197
+ fh.seek(self.offset + at)
198
+ if fh.readinto(memoryview(buf)) != buf.nbytes:
199
+ raise CorruptFileError(f"{self.handles.path!r}: pixel data ends early")
200
+
201
+
202
+ class _FrameBlock:
203
+ """An XLIF memory block assembled from frame files, each at its byte
204
+ offset: LOF frames are read in place, TIFF frames page by page
205
+ (micro-reader's TIFF backend), JPEG / PNG / BMP frames decoded whole
206
+ (micro-reader's codecs) and cached. A frame decoded as RGB where the
207
+ block holds one sample per pixel contributes its first sample (as
208
+ liffile does)."""
209
+
210
+ def __init__(self, image: "_LifFile", frames: list, size: int, where: str):
211
+ self.image, self.size, self.where = image, size, where
212
+ self.frames = sorted(frames, key=lambda f: f[1]) # (path, offset, size)
213
+ # raw LOF frames store colour B, G, R like LIF; decoded image files R, G, B
214
+ self.by_tags = all(p.lower().endswith(".lof") for p, _, _ in frames)
215
+ self.cache = DecodeCache(FRAME_CACHE_BYTES)
216
+ self._lock = threading.Lock()
217
+ self._sources: dict = {}
218
+
219
+ def readinto(self, at: int, buf: np.ndarray) -> None:
220
+ end = at + buf.nbytes
221
+ covered = 0
222
+ for path, offset, size in self.frames:
223
+ lo, hi = max(at, offset), min(end, offset + size)
224
+ if lo < hi:
225
+ self._copy(path, size, lo - offset, buf[lo - at:hi - at])
226
+ covered += hi - lo
227
+ if covered != buf.nbytes: # bytes no frame provides: background
228
+ mask = np.ones(buf.nbytes, bool)
229
+ for _, offset, size in self.frames:
230
+ lo, hi = max(at, offset), min(end, offset + size)
231
+ if lo < hi:
232
+ mask[lo - at:hi - at] = False
233
+ buf[mask] = 0
234
+
235
+ def _source(self, path: str):
236
+ """How to get a frame file's bytes, set up once per file."""
237
+ with self._lock:
238
+ if path not in self._sources:
239
+ self._sources[path] = self.image._frame_source(path, self.where)
240
+ return self._sources[path]
241
+
242
+ def _copy(self, path: str, frame_size: int, local: int, piece: np.ndarray) -> None:
243
+ kind, source = self._source(path)
244
+ if kind == "lof":
245
+ source.readinto(local, piece)
246
+ return
247
+ if kind == "tiff":
248
+ scene, lead, page_bytes = source
249
+ pages = max(1, int(np.prod(lead, dtype=np.int64)))
250
+ total = pages * page_bytes
251
+ factor = 3 if total == 3 * frame_size and scene.shape[-1] == 3 else 1
252
+ if total != frame_size * factor:
253
+ raise CorruptFileError(f"{self.where}: frame {path!r} holds {total} bytes, "
254
+ f"the XLIF says {frame_size}")
255
+ # only the page rows the piece covers, read through the TIFF
256
+ # backend (exact rectangles; compressed strips / tiles cached there)
257
+ per_page = page_bytes // factor
258
+ row_bytes = per_page // scene.shape[len(lead)]
259
+ done = 0
260
+ while done < piece.nbytes:
261
+ k, into = divmod(local + done, per_page)
262
+ n = min(piece.nbytes - done, per_page - into)
263
+ r0, r1 = into // row_bytes, (into + n - 1) // row_bytes + 1
264
+ index = tuple(int(i) for i in np.unravel_index(k, lead)) if lead else ()
265
+ rows = scene[index + (slice(r0, r1),)]
266
+ if factor == 3:
267
+ rows = rows[..., 0]
268
+ raw = np.ascontiguousarray(rows.astype(rows.dtype.newbyteorder("<"),
269
+ copy=False)).view(np.uint8).reshape(-1)
270
+ start = into - r0 * row_bytes
271
+ piece[done:done + n] = raw[start:start + n]
272
+ done += n
273
+ return
274
+ data = self._page(path, source, None, 0, None, frame_size)
275
+ piece[...] = data[local:local + piece.nbytes]
276
+
277
+ def _page(self, path, source, lead, k, factor, frame_size=None) -> np.ndarray:
278
+ """Bytes of page *k* of a TIFF frame, or of a whole image frame."""
279
+ key = (path, k)
280
+ data = self.cache.get(key)
281
+ if data is not None:
282
+ return data
283
+ if lead is not None: # TIFF page
284
+ array = source[np.unravel_index(k, lead)] if lead else source[...]
285
+ if factor == 3:
286
+ array = array[..., 0]
287
+ else: # JPEG / PNG / BMP
288
+ array = source()
289
+ if array.nbytes != frame_size:
290
+ if array.ndim == 3 and array.shape[2] == 3 and array.nbytes == 3 * frame_size:
291
+ array = array[..., 0]
292
+ else:
293
+ raise CorruptFileError(f"{self.where}: frame {path!r} decodes to "
294
+ f"{array.nbytes} bytes, the XLIF says {frame_size}")
295
+ data = np.ascontiguousarray(array).astype(array.dtype.newbyteorder("<")).view(np.uint8)
296
+ data = data.reshape(-1)
297
+ self.cache.put(key, data)
298
+ return data
299
+
300
+
301
+ # -- image files in XLIF frames ----------------------------------------------------
302
+
303
+ def _image_reader(path: str, where: str):
304
+ """A function decoding a JPEG / PNG / BMP frame file into an array
305
+ (``raster``'s decoders; a colour palette is applied: frames are colour)."""
306
+
307
+ def read():
308
+ with builtins.open(path, "rb") as fh:
309
+ data = fh.read()
310
+ try:
311
+ info = raster.info_of(data)
312
+ pixels = raster.decode(data, info)
313
+ return pixels if info.palette is None else raster.apply_palette(pixels, info.palette)
314
+ except (CorruptFileError, UnsupportedFormatError) as exc:
315
+ raise type(exc)(f"{where}: frame {path!r}: {exc}") from exc
316
+ return read
317
+
318
+
319
+ # -- images -------------------------------------------------------------------------
320
+
321
+ class _Layout:
322
+ """Where each sample of one image lies in its memory block."""
323
+
324
+ def __init__(self, element, where: str, by_tags: bool = True):
325
+ desc = element.find("Data/Image/ImageDescription")
326
+ channels = desc.findall("Channels/ChannelDescription")
327
+ dims = desc.findall("Dimensions/DimensionDescription")
328
+ if not channels or not dims:
329
+ raise UnsupportedFormatError(f"{where}: image without channels or dimensions")
330
+ # samples that do not start on a byte (bit increments) are not read
331
+ # as if they did: refused by name (liffile does not read them either)
332
+ packed = [e.tag for e in channels + dims if int(e.get("BitInc", 0) or 0)]
333
+ if packed:
334
+ raise UnsupportedFormatError(
335
+ f"{where}: bit-packed samples (non-zero BitInc in {sorted(set(packed))})")
336
+ types = {(int(c.get("DataType", 0)), int(c.get("Resolution", 8))) for c in channels}
337
+ if len(types) != 1:
338
+ raise UnsupportedFormatError(f"{where}: channels of different types {sorted(types)}")
339
+ self.dtype = _dtype(*types.pop(), where)
340
+ item = self.dtype.itemsize
341
+
342
+ self.size, self.stride = {}, {} # axis -> elements, bytes
343
+ for d in dims:
344
+ dim_id, n = int(d.get("DimID")), int(d.get("NumberOfElements"))
345
+ inc = int(d.get("BytesInc"))
346
+ axis = {1: "x", 2: "y"}.get(dim_id) or DIM_AXES.get(dim_id) or DIM_KEYS.get(dim_id)
347
+ if axis is None:
348
+ if n > 1:
349
+ raise UnsupportedFormatError(
350
+ f"{where}: LIF dimension {DIM_NAMES.get(dim_id, dim_id)} "
351
+ f"(DimID {dim_id}) with {n} elements")
352
+ continue
353
+ self.size[axis], self.stride[axis] = n, inc
354
+ if "x" not in self.size or "y" not in self.size:
355
+ raise UnsupportedFormatError(f"{where}: image without X and Y dimensions")
356
+
357
+ offsets = [int(c.get("BytesInc", 0)) for c in channels]
358
+ tags = [int(c.get("ChannelTag", 0)) for c in channels]
359
+ pixel = self.stride["x"]
360
+ # interleaved colour: 3 (4) samples inside one pixel, tagged R, G, B
361
+ self.samples = 1
362
+ if len(channels) in (3, 4) and max(offsets) + item <= pixel and sorted(tags[:3]) == [1, 2, 3]:
363
+ if by_tags: # raw: R, G, B by tag
364
+ order = sorted(range(3), key=lambda k: tags[k]) + list(range(3, len(channels)))
365
+ else: # decoded frames: as stored
366
+ order = sorted(range(len(channels)), key=lambda k: offsets[k])
367
+ self.sample_offsets = [offsets[k] for k in order]
368
+ self.samples = len(channels)
369
+ self.channel_offsets = [0]
370
+ else:
371
+ if pixel < item:
372
+ raise CorruptFileError(f"{where}: pixel stride {pixel} < sample size {item}")
373
+ self.sample_offsets = [0]
374
+ self.channel_offsets = offsets
375
+ self.size["c"] = len(self.channel_offsets)
376
+ self.axes = list(PLANE_AXES)
377
+
378
+ def extent(self) -> int:
379
+ """Bytes from the block start to the end of the last sample."""
380
+ last = max(self.channel_offsets) + max(self.sample_offsets) + self.dtype.itemsize
381
+ return last + sum((self.size[a] - 1) * self.stride[a]
382
+ for a in self.stride if self.size.get(a, 1) > 1)
383
+
384
+
385
+ class _LifImage(Image):
386
+ """One tile / rotation of one Leica image."""
387
+
388
+ def __init__(self, index: int, element, name: str, block, layout: "_Layout", key: dict):
389
+ self._layout = layout
390
+ self._block = block
391
+ self._element = element
392
+ # the tile / rotation of this image: a fixed offset into the block
393
+ self._offset = sum(key.get(level, 0) * layout.stride.get(level, 0)
394
+ for level in OTHER_INDICES)
395
+ shape = [layout.size.get(a, 1) for a in layout.axes] + [layout.size["y"], layout.size["x"]]
396
+ letters = "".join(layout.axes) + "yx"
397
+ if layout.samples > 1:
398
+ shape.append(layout.samples)
399
+ letters += "s"
400
+ self._closed = False
401
+ super().__init__(index, letters, shape, layout.dtype, name=name, index=key)
402
+
403
+ @property
404
+ def tile_position(self):
405
+ """Deprecated: use ``image.metadata.position`` (the tile's position,
406
+ as for every format). This tile's raw (FieldX, FieldY, PosX, PosY)
407
+ from the TileScanInfo attachment; None for an image without tiles."""
408
+ warnings.warn("LifImage.tile_position is deprecated: use image.metadata.position",
409
+ DeprecationWarning, stacklevel=2)
410
+ if self._layout.size.get("tile", 1) <= 1:
411
+ return None
412
+ tiles = self._element.findall("Data/Image/Attachment[@Name='TileScanInfo']/Tile")
413
+ m = self.index.get("tile", 0)
414
+ if m >= len(tiles):
415
+ return None
416
+ t = tiles[m]
417
+ return (int(t.get("FieldX", 0)), int(t.get("FieldY", 0)),
418
+ float(t.get("PosX", 0)), float(t.get("PosY", 0)))
419
+
420
+ def _read_metadata(self):
421
+ from ._lif_metadata import lif_metadata
422
+ return lif_metadata(self._element, dict(zip(self.axes, self.shape)), self.index,
423
+ tiled=self._layout.size.get("tile", 1) > 1)
424
+
425
+ def _read_block(self, block):
426
+ if self._closed:
427
+ raise ValueError("this LIF image has been closed: its file was closed "
428
+ "(File.close() or the end of a with block)")
429
+ lay = self._layout
430
+ n = len(lay.axes)
431
+ plane_spans, ys, xs = block[:n], block[n], block[n + 1]
432
+ ss = block[n + 2] if lay.samples > 1 else slice(0, 1)
433
+ out = np.empty(tuple(s.stop - s.start for s in block), dtype=self.dtype)
434
+ view = out if lay.samples > 1 else out[..., None]
435
+ per_task = max(1, RAW_TASK_BYTES // max(1, lay.stride["y"]))
436
+ tasks = []
437
+ for key in itertools.product(*(range(s.start, s.stop) for s in plane_spans)):
438
+ base = self._offset
439
+ for axis, k in zip(lay.axes, key):
440
+ base += lay.channel_offsets[k] if axis == "c" else k * lay.stride.get(axis, 0)
441
+ dst = view[tuple(k - s.start for k, s in zip(key, plane_spans))]
442
+ for y0 in range(ys.start, ys.stop, per_task):
443
+ y1 = min(ys.stop, y0 + per_task)
444
+ tasks.append(lambda a=(base, y0, y1, xs, ss, dst[y0 - ys.start:y1 - ys.start]):
445
+ self._read_rows(*a))
446
+ return out, _pool.run(tasks)
447
+
448
+ def _read_rows(self, base, y0, y1, xs, ss, dst) -> int:
449
+ """Rows y0:y1, columns *xs*, samples *ss* of one plane at block
450
+ offset *base* into *dst* (rows, cols, samples). One read spanning
451
+ the rows when the bytes skipped between them cost less than
452
+ separate reads."""
453
+ lay = self._layout
454
+ sy, sx, item = lay.stride["y"], lay.stride["x"], lay.dtype.itemsize
455
+ rows, width = y1 - y0, xs.stop - xs.start
456
+ lowest = min(lay.sample_offsets)
457
+ first = base + y0 * sy + xs.start * sx + lowest
458
+ row_span = (width - 1) * sx + (max(lay.sample_offsets) - lowest) + item
459
+ skipped = (sy - row_span) * (rows - 1)
460
+ samples = [lay.sample_offsets[k] - lowest for k in range(ss.start, ss.stop)]
461
+ if rows == 1 or skipped <= (rows - 1) * SEPARATE_READ_BYTES:
462
+ span = (rows - 1) * sy + row_span
463
+ buf = np.empty(span, np.uint8)
464
+ self._block.readinto(first, buf)
465
+ for k, off in enumerate(samples):
466
+ dst[..., k] = np.ndarray((rows, width), lay.dtype, buf, off, (sy, sx))
467
+ return span // item
468
+ buf = np.empty(row_span, np.uint8)
469
+ for r in range(rows):
470
+ self._block.readinto(first + r * sy, buf)
471
+ for k, off in enumerate(samples):
472
+ dst[r, :, k] = np.ndarray((width,), lay.dtype, buf, off, (sx,))
473
+ return rows * row_span // item
474
+
475
+ def close(self) -> None:
476
+ self._closed = True
477
+
478
+
479
+ class _LifFile(File):
480
+ backend = NAME
481
+
482
+ def __init__(self, path: str, tiles: str = "separate"):
483
+ self.path = path
484
+ self._handles: dict = {} # binary file -> ThreadHandles
485
+ self._tiffs: list = [] # TIFF frame files opened
486
+ self._lofs: dict = {} # LOF frame file -> its _FileBlock
487
+ self.format = None # of the file opened: lif, lof, lifext, xlif, ...
488
+ try:
489
+ entries = self._collect(path, frozenset())
490
+ if not entries:
491
+ raise CorruptFileError(f"{path!r}: no images")
492
+ layouts = []
493
+ for name, element, block, where in entries:
494
+ layout = _Layout(element, where, block.by_tags)
495
+ if layout.extent() > block.size:
496
+ raise CorruptFileError(f"{where}: pixels need {layout.extent()} bytes, "
497
+ f"its memory block holds {block.size}")
498
+ layouts.append(layout)
499
+ # indices: those some image of the file has more than one of
500
+ levels = [level for level in OTHER_INDICES
501
+ if any(lay.size.get(level, 1) > 1 for lay in layouts)]
502
+ if tiles == "stitched" and "tile" in levels:
503
+ raise UnsupportedFormatError(
504
+ f"{path!r}: tiles='stitched': micro-reader does not compose Leica tiles "
505
+ "(each tile is its own image; image.metadata.position gives its place)")
506
+ images = []
507
+ for n, ((name, element, block, _), layout) in enumerate(zip(entries, layouts)):
508
+ for values in itertools.product(*(range(layout.size.get(level, 1))
509
+ for level in levels)):
510
+ key = {"scene": n, **dict(zip(levels, values))}
511
+ images.append(_LifImage(len(images), element, name, block, layout, key))
512
+ except BaseException:
513
+ self._close_files()
514
+ raise
515
+ super().__init__(path, images)
516
+
517
+ # -- reading the file tree ----------------------------------------------------
518
+
519
+ def _collect(self, path: str, seen: frozenset) -> list:
520
+ """(name, element, block, where) of every image in *path* and the
521
+ files it references."""
522
+ real = os.path.normcase(os.path.abspath(path))
523
+ if real in seen:
524
+ raise CorruptFileError(f"{path!r}: files reference each other in a cycle")
525
+ seen = seen | {real}
526
+ with builtins.open(path, "rb") as fh:
527
+ head = fh.read(13)
528
+ if len(head) == 13 and struct.unpack_from("<iiB", head)[::2] == (MAGIC, STAR):
529
+ return self._binary(path, fh)
530
+ fh.seek(0)
531
+ data = fh.read()
532
+ return self._xml(path, data, seen)
533
+
534
+ def _flavour(self, name: str) -> None:
535
+ if self.format is None:
536
+ self.format = name.lower()
537
+
538
+ def _handles_for(self, path: str) -> ThreadHandles:
539
+ if path not in self._handles:
540
+ self._handles[path] = ThreadHandles(path)
541
+ return self._handles[path]
542
+
543
+ def _binary(self, path: str, fh) -> list:
544
+ """LIF, LOF or LIFEXT."""
545
+ fh.seek(0)
546
+ _magic, _length, _star, nchars = struct.unpack("<iiBi", fh.read(13))
547
+ raw = fh.read(2 * nchars)
548
+ if len(raw) != 2 * nchars:
549
+ raise CorruptFileError(f"{path!r}: truncated XML header")
550
+ text = raw.decode("utf-16-le")
551
+ where = os.path.basename(path)
552
+ if text == LOF_TEXT:
553
+ self._flavour("LOF")
554
+ root, block = self._lof(path, fh)
555
+ top = root.find("Element")
556
+ return list(self._images(top, [top.get("Name", "")], block_of=lambda _e: block,
557
+ where=where))
558
+ root = self._parse_xml(text, path)
559
+ if root.tag == EXT_ROOT:
560
+ self._flavour("LIFEXT")
561
+ block_of = self._blocks_by_id(_memory_blocks(fh, 2, path), self._handles_for(path))
562
+ out = []
563
+ for group in root.findall("ChildrenOf"):
564
+ for element in group.findall("Element"):
565
+ out += self._images(element, [group.get("MemoryBlockID", ""),
566
+ element.get("Name", "")],
567
+ block_of=block_of, where=where, drop_root=False)
568
+ return out
569
+ self._flavour("LIF")
570
+ block_of = self._blocks_by_id(_memory_blocks(fh, int(root.get("Version", 1)), path),
571
+ self._handles_for(path))
572
+ top = root.find("Element")
573
+ return list(self._images(top, [top.get("Name", "")], block_of=block_of, where=where))
574
+
575
+ def _lof(self, path: str, fh):
576
+ """The single memory block of a LOF file and its XML (at the end)."""
577
+ head = fh.read(10 + 9)
578
+ if len(head) != 19:
579
+ raise CorruptFileError(f"{path!r}: truncated LOF header")
580
+ star0, _v0, star1, _v1 = struct.unpack_from("<BIBI", head)
581
+ star2, size = struct.unpack_from("<BQ", head, 10)
582
+ if star0 != STAR or star1 != STAR or star2 != STAR:
583
+ raise CorruptFileError(f"{path!r}: corrupt LOF header")
584
+ offset = fh.tell()
585
+ fh.seek(offset + size)
586
+ tail = fh.read(13)
587
+ if len(tail) != 13:
588
+ raise CorruptFileError(f"{path!r}: LOF data ends early (truncated file?)")
589
+ magic, _length, star, nchars = struct.unpack("<iiBi", tail)
590
+ if magic != MAGIC or star != STAR:
591
+ raise CorruptFileError(f"{path!r}: no XML block after the LOF data")
592
+ text = fh.read(2 * nchars).decode("utf-16-le")
593
+ if text.startswith("<Data>"):
594
+ # some (older) LOF files have no LMSDataContainerHeader, just <Data>
595
+ name = os.path.splitext(os.path.basename(path))[0]
596
+ text = (f'<LMSDataContainerHeader Version="2"><Element Name="{name}">{text}'
597
+ "</Element></LMSDataContainerHeader>")
598
+ root = self._parse_xml(text, path)
599
+ return root, _FileBlock(self._handles_for(path), offset, size)
600
+
601
+ def _xml(self, path: str, data: bytes, seen: frozenset) -> list:
602
+ """XLIF, XLEF or XLCF."""
603
+ codec = _xml_codec(data[:4])
604
+ if codec is None:
605
+ raise UnsupportedFormatError(f"{path!r}: not a Leica image file")
606
+ text = data.decode(codec)
607
+ root = self._parse_xml(text, path)
608
+ element = root.find("Element")
609
+ if element is None:
610
+ raise CorruptFileError(f"{path!r}: no Element in the XML")
611
+ dirname, where = os.path.dirname(path), os.path.basename(path)
612
+ if element.find("Data/Image") is not None: # XLIF
613
+ self._flavour("XLIF")
614
+ memory = element.find("Memory")
615
+ if memory is None:
616
+ raise CorruptFileError(f"{where}: image without a Memory element")
617
+ frames = [(_resolve(dirname, f.get("File"), where), int(f.get("Offset", 0)),
618
+ int(f.get("Size", 0))) for f in memory if "File" in f.attrib]
619
+ size = int(memory.get("Size", 0))
620
+ if not frames or not size:
621
+ return []
622
+ block = _FrameBlock(self, frames, size, where)
623
+ return list(self._images(element, [element.get("Name", "")],
624
+ block_of=lambda _e: block, where=where))
625
+ kind = "XLCF" if element.find("Data/Collection") is not None else "XLEF"
626
+ self._flavour(kind)
627
+ prefix = element.get("Name", "") if kind == "XLCF" else None
628
+ out = []
629
+ for ref in element.findall("Children/Reference"):
630
+ if ref.get("File") is None:
631
+ continue
632
+ child = _resolve(dirname, ref.get("File"), where)
633
+ for name, el, block, w in self._collect(child, seen):
634
+ out.append((f"{prefix}/{name}" if prefix else name, el, block, w))
635
+ return out
636
+
637
+ @staticmethod
638
+ def _parse_xml(text: str, path: str):
639
+ text = re.sub(r"^\s*<\?xml[^>]*\?>", "", text.lstrip(""))
640
+ try:
641
+ return ET.fromstring(text)
642
+ except ET.ParseError as exc:
643
+ raise CorruptFileError(f"{path!r}: XML: {exc}") from exc
644
+
645
+ @staticmethod
646
+ def _blocks_by_id(blocks: dict, handles: ThreadHandles):
647
+ def block_of(element):
648
+ memory = element.find("Memory")
649
+ found = blocks.get(memory.get("MemoryBlockID")) if memory is not None else None
650
+ return _FileBlock(handles, *found) if found and found[1] else None
651
+ return block_of
652
+
653
+ def _images(self, element, parts, *, block_of, where, drop_root=True):
654
+ """(name, element, block, where) of every image with pixels at and
655
+ below *element*, in document order. *parts*: the element's name
656
+ path. Names join it with '/', leaving out the file's root element
657
+ (LIF: the file; LOF / XLIF: the image itself, whose own name is kept
658
+ when it is the image) unless *drop_root* is false (LIFEXT)."""
659
+ if element.find("Data/Image") is not None:
660
+ block = block_of(element)
661
+ name = "/".join(parts[1:] if drop_root and len(parts) > 1 else parts)
662
+ if block is not None:
663
+ yield name, element, block, f"{where!r} image {name!r}"
664
+ for child in element.findall("Children/Element"):
665
+ yield from self._images(child, parts + [child.get("Name", "")], block_of=block_of,
666
+ where=where, drop_root=drop_root)
667
+
668
+ # -- XLIF frame files -----------------------------------------------------------
669
+
670
+ def _frame_source(self, path: str, where: str):
671
+ """(kind, source) for a frame file: ("lof", its _FileBlock),
672
+ ("tiff", (scene, lead shape, page bytes)) or ("image", decoder)."""
673
+ ext = os.path.splitext(path)[1].lower()
674
+ if ext == ".lof":
675
+ if path not in self._lofs:
676
+ with builtins.open(path, "rb") as fh:
677
+ magic, _length, star, nchars = struct.unpack("<iiBi", fh.read(13))
678
+ if (magic, star) != (MAGIC, STAR) or \
679
+ fh.read(2 * nchars).decode("utf-16-le", "replace") != LOF_TEXT:
680
+ raise CorruptFileError(f"{where}: frame {path!r} is not a LOF file")
681
+ _root, block = self._lof(path, fh)
682
+ self._lofs[path] = block
683
+ return "lof", self._lofs[path]
684
+ if ext in TIFF_FRAMES:
685
+ from . import tiff as tiff_backend
686
+ img = tiff_backend.open(path)
687
+ self._tiffs.append(img)
688
+ scene = img.images[0]
689
+ lead = tuple(scene._lead_shape)
690
+ page_shape = scene.shape[len(lead):]
691
+ page_bytes = int(np.prod(page_shape, dtype=np.int64)) * scene.dtype.itemsize
692
+ return "tiff", (scene, lead, page_bytes)
693
+ if ext in IMAGE_FRAMES:
694
+ return "image", _image_reader(path, where)
695
+ raise UnsupportedFormatError(f"{where}: frame file type {ext!r} ({path!r})")
696
+
697
+ # -- closing --------------------------------------------------------------------
698
+
699
+ def _close_files(self) -> None:
700
+ for handles in self._handles.values():
701
+ handles.close()
702
+ self._handles.clear()
703
+ for img in self._tiffs:
704
+ img.close()
705
+ self._tiffs.clear()
706
+
707
+ def close(self) -> None:
708
+ super().close()
709
+ self._close_files()
710
+
711
+
712
+ def open(path: str, tiles: str = "separate", resolutions: str = "base") -> _LifFile: # noqa: A001 - backend protocol
713
+ return _LifFile(path, tiles)