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