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,125 @@
|
|
|
1
|
+
"""Metadata of Leica images (docs/METADATA.md), from the image's XML
|
|
2
|
+
element.
|
|
3
|
+
|
|
4
|
+
- Pixel sizes: ``DimensionDescription`` ``Length`` (first to last sample,
|
|
5
|
+
in ``Unit``; negative for a z stack taken downwards) over
|
|
6
|
+
``NumberOfElements - 1``, for x, y, z and t (DimID 1, 2, 3, 4) -- as
|
|
7
|
+
Bio-Formats computes them.
|
|
8
|
+
- Channels: colour from ``ChannelDescription`` ``LUTName``; names: the dye
|
|
9
|
+
names (``MultiBand DyeName``) of the active detectors of the image's
|
|
10
|
+
confocal setting, only when there is one per channel (sequential scans
|
|
11
|
+
spread channels over several settings: names unknown). Channel
|
|
12
|
+
wavelengths: out of scope for now.
|
|
13
|
+
- Index values (D5): the image's wavelength along a lambda (``emission``,
|
|
14
|
+
DimID 5) or excitation (``excitation``, DimID 9) dimension, ``Origin`` +
|
|
15
|
+
k x ``Length`` / (N - 1), in nm.
|
|
16
|
+
- Position (the first pixel along the image's axes): the setting's
|
|
17
|
+
``StagePosX`` / ``StagePosY`` -- for a tile, its ``TileScanInfo`` PosX /
|
|
18
|
+
PosY -- plus the x / y dimensions' ``Origin``. Verified on a LAS tile
|
|
19
|
+
scan and its merged image (harness/verify_positions.py): the positions
|
|
20
|
+
are along the image's axes (the scanner's SwapXY / FlipX / FlipY do not
|
|
21
|
+
apply to them), and the merged image's first pixel is its first tile's.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .._metadata import Channel, Metadata, Quantity, color_hex, quantity
|
|
26
|
+
|
|
27
|
+
#: LAS lookup table names -> colour; "Gray" is no colour
|
|
28
|
+
LUT_COLORS = {"red": "#FF0000", "green": "#00FF00", "blue": "#0000FF", "cyan": "#00FFFF",
|
|
29
|
+
"magenta": "#FF00FF", "yellow": "#FFFF00"}
|
|
30
|
+
DIM_SIZE_AXES = {1: "x", 2: "y", 3: "z", 4: "t"}
|
|
31
|
+
#: dimensions whose samples are wavelengths (D5): emission lambda, excitation
|
|
32
|
+
WAVELENGTH_INDICES = {5: "emission", 9: "excitation"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _setting(element):
|
|
36
|
+
for tag in ("ATLConfocalSettingDefinition", "ATLCameraSettingDefinition"):
|
|
37
|
+
found = element.find(f"Data/Image/Attachment[@Name='HardwareSetting']/{tag}")
|
|
38
|
+
if found is None:
|
|
39
|
+
found = next(element.iter(tag), None)
|
|
40
|
+
if found is not None:
|
|
41
|
+
return found
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _stage_position(element, setting, tile) -> dict:
|
|
46
|
+
"""The stage position recorded for the image (*tile*: None) or for its
|
|
47
|
+
tile (``TileScanInfo`` PosX / PosY): {axis: Quantity}. Both are along
|
|
48
|
+
the image's own axes -- checked on a LAS tile scan against LAS's merged
|
|
49
|
+
image: a tile with +PosX sits further along x, whatever the scanner's
|
|
50
|
+
SwapXY / FlipX / FlipY (which describe the scan, not the stage)."""
|
|
51
|
+
if tile is not None:
|
|
52
|
+
info = element.find("Data/Image/Attachment[@Name='TileScanInfo']")
|
|
53
|
+
tiles = info.findall("Tile") if info is not None else []
|
|
54
|
+
if tile >= len(tiles) or any(info.get(k, "0") != "0" for k in ("SwapXY", "FlipX", "FlipY")):
|
|
55
|
+
return {} # tile positions in another frame: not verified
|
|
56
|
+
source, names = tiles[tile], ("PosX", "PosY")
|
|
57
|
+
elif setting is not None:
|
|
58
|
+
source, names = setting, ("StagePosX", "StagePosY")
|
|
59
|
+
else:
|
|
60
|
+
return {}
|
|
61
|
+
out = {a: quantity(source.get(n), "m", positive=False) for a, n in zip("xy", names)}
|
|
62
|
+
return out if all(out.values()) else {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def lif_metadata(element, shape: dict, index: dict, tiled: bool = False) -> Metadata:
|
|
66
|
+
"""*index*: the image's index; *tiled*: whether it is a tile of a tile scan."""
|
|
67
|
+
tile = index.get("tile", 0) if tiled else None
|
|
68
|
+
desc = element.find("Data/Image/ImageDescription")
|
|
69
|
+
sizes = {}
|
|
70
|
+
for d in desc.findall("Dimensions/DimensionDescription"):
|
|
71
|
+
axis = DIM_SIZE_AXES.get(int(d.get("DimID", 0)))
|
|
72
|
+
n = int(d.get("NumberOfElements", 1))
|
|
73
|
+
if axis is None or n < 2:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
length = abs(float(d.get("Length")))
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
continue
|
|
79
|
+
q = quantity(length / (n - 1), d.get("Unit") or ("s" if axis == "t" else "m"))
|
|
80
|
+
if q:
|
|
81
|
+
sizes[axis] = q
|
|
82
|
+
index_values = {}
|
|
83
|
+
for d in desc.findall("Dimensions/DimensionDescription"):
|
|
84
|
+
name = WAVELENGTH_INDICES.get(int(d.get("DimID", 0)))
|
|
85
|
+
n = int(d.get("NumberOfElements", 1))
|
|
86
|
+
k = index.get(name) if name else None
|
|
87
|
+
if k is None or n < 2:
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
origin, length = float(d.get("Origin", 0)), float(d.get("Length"))
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
continue
|
|
93
|
+
q = quantity(origin + k * length / (n - 1), d.get("Unit") or "m", positive=False)
|
|
94
|
+
if q:
|
|
95
|
+
index_values[name] = q.to("nanometer")
|
|
96
|
+
descriptions = desc.findall("Channels/ChannelDescription")
|
|
97
|
+
n_c = max(1, shape.get("c", 1))
|
|
98
|
+
colors = [LUT_COLORS.get((c.get("LUTName") or "").lower()) for c in descriptions]
|
|
99
|
+
if shape.get("s", 1) > 1: # interleaved RGB: one channel, no LUT colour
|
|
100
|
+
colors = []
|
|
101
|
+
setting = _setting(element)
|
|
102
|
+
names = []
|
|
103
|
+
if setting is not None:
|
|
104
|
+
bands = {b.get("Channel"): b.get("DyeName") or None for b in setting.iter("MultiBand")}
|
|
105
|
+
active = [d.get("Channel") for d in setting.iter("Detector") if d.get("IsActive") == "1"]
|
|
106
|
+
if len(active) == n_c:
|
|
107
|
+
names = [bands.get(ch) for ch in active]
|
|
108
|
+
channels = []
|
|
109
|
+
for k in range(n_c):
|
|
110
|
+
channels.append(Channel(name=names[k] if k < len(names) else None,
|
|
111
|
+
color=colors[k] if k < len(colors) else None))
|
|
112
|
+
position = {}
|
|
113
|
+
stage = _stage_position(element, setting, tile)
|
|
114
|
+
if stage:
|
|
115
|
+
# Origin: the first sample's place relative to the stage position (a
|
|
116
|
+
# panned scan's offset; 0 when not panned), along the image's axes
|
|
117
|
+
for d in desc.findall("Dimensions/DimensionDescription"):
|
|
118
|
+
axis = {1: "x", 2: "y"}.get(int(d.get("DimID", 0)))
|
|
119
|
+
if axis in stage:
|
|
120
|
+
origin = quantity(d.get("Origin", 0), d.get("Unit") or "m", positive=False)
|
|
121
|
+
q = stage[axis]
|
|
122
|
+
position[axis] = Quantity(q.value + (origin.to(q.unit).value if origin else 0.0),
|
|
123
|
+
q.unit)
|
|
124
|
+
return Metadata(pixel_sizes=sizes, position=position, channels=tuple(channels),
|
|
125
|
+
index_values=index_values)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Legacy ND2 (Nikon NIS-Elements, format versions 1 and 2), numpy only.
|
|
2
|
+
|
|
3
|
+
A JPEG 2000 box file (it starts with the JP2 signature box): a box map at
|
|
4
|
+
the end -- signature ``LABORATORY IMAGING ND BOX MAP 00`` and its offset --
|
|
5
|
+
lists every box by type; ``LUNK`` boxes are the frames' JPEG 2000
|
|
6
|
+
codestreams, one per channel per frame (an RGB channel is a 3-component
|
|
7
|
+
codestream); XML boxes in Nikon's old dialect describe the image (``VIMD``
|
|
8
|
+
per frame, ``AIM1`` / ``AIMD`` the experiment loops, ``TINF`` the text
|
|
9
|
+
description, ``ARTT`` the significant bits); ``jp2h`` / ``ihdr`` gives
|
|
10
|
+
height, width and bits per component.
|
|
11
|
+
|
|
12
|
+
Frames run over the experiment loops, outermost first (time, XY position,
|
|
13
|
+
Z), loops of one element left out; loop counts follow the text description
|
|
14
|
+
where it names them (some files store more frames than the loops hold).
|
|
15
|
+
Images are the XY positions (index ``scene``); axes ``tczyx`` plus ``s`` for RGB, as the modern
|
|
16
|
+
ND2 backend has them. Codestreams are decoded whole (micro-reader's JPEG
|
|
17
|
+
2000 decoder, on the decode pool) and kept in a bounded cache.
|
|
18
|
+
|
|
19
|
+
Structure ported, reduced, from nd2 (https://github.com/tlambert03/nd2),
|
|
20
|
+
``_readers/_legacy/legacy_reader.py`` and ``_parse/_legacy_xml.py``:
|
|
21
|
+
Copyright (c) 2021 Talley Lambert, BSD-3-Clause (full notice in
|
|
22
|
+
``_nd2_structure.py``).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import builtins
|
|
27
|
+
import itertools
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import struct
|
|
31
|
+
import threading
|
|
32
|
+
import xml.etree.ElementTree as ET
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from functools import partial
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from .. import _codecs, _memory, _pool
|
|
39
|
+
from .._base import File, Image
|
|
40
|
+
from .._errors import CorruptFileError, UnsupportedFormatError
|
|
41
|
+
from .._io import DecodeCache, ThreadHandles
|
|
42
|
+
|
|
43
|
+
JP2_SIGNATURE = b"\x00\x00\x00\x0cjP \r\n\x87\n"
|
|
44
|
+
MAP_SIGNATURE = b"LABORATORY IMAGING ND BOX MAP 00"
|
|
45
|
+
BOX = struct.Struct(">I4s")
|
|
46
|
+
MAP_ENTRY = struct.Struct("<4s4sI") # box type, Nikon type, offset
|
|
47
|
+
MAP_STRIDE = 16 # map entries are 16 bytes apart (4 unused)
|
|
48
|
+
IHDR = struct.Struct(">iihBB") # height, width, components, bits - 1, codec
|
|
49
|
+
DECODE_CACHE_BYTES = 128 << 20
|
|
50
|
+
#: legacy experiment loop type -> axis
|
|
51
|
+
LOOP_AXES = {8: "T", 2: "P", 4: "Z"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_legacy(head: bytes) -> bool:
|
|
55
|
+
return head[:12] == JP2_SIGNATURE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# -- Nikon's old XML dialect (nd2 ``_parse/_legacy_xml.py``) ----------------------------
|
|
59
|
+
|
|
60
|
+
_LOWER = re.compile("^[a-z_]+")
|
|
61
|
+
_TYPES = {"bool": bool, "lx_uint32": int, "lx_uint64": int, "lx_int32": int,
|
|
62
|
+
"lx_int64": int, "double": float, "CLxStringW": str,
|
|
63
|
+
"CLxByteArray": partial(bytes, encoding="utf-8"), "unknown": str, None: str}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _elem2dict(node):
|
|
67
|
+
if "value" in node.attrib:
|
|
68
|
+
convert = _TYPES.get(node.attrib.get("runtype"), str)
|
|
69
|
+
try:
|
|
70
|
+
return convert(node.attrib["value"])
|
|
71
|
+
except ValueError:
|
|
72
|
+
return node.attrib["value"]
|
|
73
|
+
result = {k: v for k, v in node.attrib.items() if k not in ("runtype", "version")}
|
|
74
|
+
for element in node:
|
|
75
|
+
key = element.tag.split("}")[1] if "}" in element.tag else element.tag
|
|
76
|
+
key = _LOWER.sub("", key) or key
|
|
77
|
+
value = element.text if element.text and element.text.strip() else _elem2dict(element)
|
|
78
|
+
if key in result:
|
|
79
|
+
result[key] = (result[key] + [value]) if isinstance(result[key], list) \
|
|
80
|
+
else [result[key], value]
|
|
81
|
+
else:
|
|
82
|
+
result[key] = value
|
|
83
|
+
if "variant" in result and not isinstance(result["variant"], list):
|
|
84
|
+
result = result["variant"]
|
|
85
|
+
if isinstance(result, dict) and set(result) == {"no_name"} \
|
|
86
|
+
and not isinstance(result["no_name"], list):
|
|
87
|
+
result = result["no_name"]
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_xml(raw: bytes) -> dict:
|
|
92
|
+
try:
|
|
93
|
+
return _elem2dict(ET.fromstring(raw.split(b"?>", 1)[-1]))
|
|
94
|
+
except ET.ParseError as exc:
|
|
95
|
+
raise CorruptFileError(f"legacy ND2 metadata: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_DIMSIZE = re.compile(r"(\w+)'?\((\d+)\)")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _described_sizes(description: str | None) -> dict:
|
|
102
|
+
"""Axis sizes named in the text description ('Dimensions: T(5) x XY(3)
|
|
103
|
+
x λ(2)'), XY as 'P' and λ as 'C'."""
|
|
104
|
+
if not description:
|
|
105
|
+
return {}
|
|
106
|
+
match = re.search(r"Dimensions:\s?([^\r]+)\r?\n", description)
|
|
107
|
+
if not match:
|
|
108
|
+
return {}
|
|
109
|
+
dims = match.group(1).replace("λ", "C").replace("XY", "P")
|
|
110
|
+
return {k: int(v) for k, v in _DIMSIZE.findall(dims)}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -- the file -----------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
class LegacyNd2Parser:
|
|
116
|
+
"""What the images of one legacy file share: boxes, layout, loops."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, path: str):
|
|
119
|
+
self.path = path
|
|
120
|
+
with builtins.open(path, "rb") as fh:
|
|
121
|
+
if not is_legacy(fh.read(12)):
|
|
122
|
+
raise UnsupportedFormatError(f"{path!r}: not a legacy ND2 file")
|
|
123
|
+
self.boxes = self._box_map(fh)
|
|
124
|
+
self._fh = fh
|
|
125
|
+
try:
|
|
126
|
+
self._describe()
|
|
127
|
+
finally:
|
|
128
|
+
self._fh = None
|
|
129
|
+
self.handles = ThreadHandles(path)
|
|
130
|
+
|
|
131
|
+
def _box_map(self, fh) -> dict:
|
|
132
|
+
fh.seek(0, 2)
|
|
133
|
+
if fh.tell() < 40:
|
|
134
|
+
raise CorruptFileError(f"{self.path!r}: truncated legacy ND2 file")
|
|
135
|
+
fh.seek(-40, 2)
|
|
136
|
+
signature, map_start = struct.unpack("<32sQ", fh.read(40))
|
|
137
|
+
if signature != MAP_SIGNATURE:
|
|
138
|
+
raise CorruptFileError(f"{self.path!r}: no legacy ND2 box map (truncated file?)")
|
|
139
|
+
fh.seek(-map_start, 2)
|
|
140
|
+
count = int.from_bytes(fh.read(4), "big")
|
|
141
|
+
data = fh.read()
|
|
142
|
+
if len(data) < (count - 1) * MAP_STRIDE + MAP_ENTRY.size:
|
|
143
|
+
raise CorruptFileError(f"{self.path!r}: truncated legacy ND2 box map")
|
|
144
|
+
boxes = defaultdict(list)
|
|
145
|
+
for i in range(count):
|
|
146
|
+
box, kind, offset = MAP_ENTRY.unpack_from(data, i * MAP_STRIDE)
|
|
147
|
+
boxes[box if box in (b"jP ", b"ftyp", b"jp2h") else kind].append(offset)
|
|
148
|
+
return dict(boxes)
|
|
149
|
+
|
|
150
|
+
def _box(self, kind: bytes, index: int = 0, fh=None) -> bytes:
|
|
151
|
+
"""Contents of the *index*-th box of *kind* (without its header)."""
|
|
152
|
+
offsets = self.boxes.get(kind, [])
|
|
153
|
+
if index >= len(offsets):
|
|
154
|
+
raise KeyError(kind)
|
|
155
|
+
fh = fh or self._fh or self.handles.get()
|
|
156
|
+
fh.seek(offsets[index])
|
|
157
|
+
length, _ = BOX.unpack(fh.read(BOX.size))
|
|
158
|
+
data = fh.read(length - BOX.size)
|
|
159
|
+
if len(data) != length - BOX.size:
|
|
160
|
+
raise CorruptFileError(f"{self.path!r}: box {kind!r} ends early")
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
def _xml(self, kind: bytes, index: int = 0) -> dict:
|
|
164
|
+
try:
|
|
165
|
+
return _parse_xml(self._box(kind, index))
|
|
166
|
+
except KeyError:
|
|
167
|
+
return {}
|
|
168
|
+
|
|
169
|
+
def _describe(self) -> None:
|
|
170
|
+
# frame size and depth: the JP2 header box
|
|
171
|
+
if not self.boxes.get(b"jp2h"):
|
|
172
|
+
raise CorruptFileError(f"{self.path!r}: no JP2 header box")
|
|
173
|
+
fh = self._fh
|
|
174
|
+
fh.seek(self.boxes[b"jp2h"][0] + BOX.size + 4)
|
|
175
|
+
if fh.read(4) != b"ihdr":
|
|
176
|
+
raise CorruptFileError(f"{self.path!r}: no image header in the JP2 header")
|
|
177
|
+
self.height, self.width, _nc, bits_minus_one, _codec = IHDR.unpack(fh.read(IHDR.size))
|
|
178
|
+
bits = bits_minus_one + 1
|
|
179
|
+
if bits > 16:
|
|
180
|
+
raise UnsupportedFormatError(f"{self.path!r}: legacy ND2 with {bits}-bit samples")
|
|
181
|
+
self.dtype = np.dtype(np.uint8 if bits <= 8 else np.uint16)
|
|
182
|
+
# channels and components: frame 0's picture planes
|
|
183
|
+
try:
|
|
184
|
+
planes = self._xml(b"VIMD", 0)["PicturePlanes"]
|
|
185
|
+
self.channels, components = int(planes["Count"]), int(planes["CompCount"])
|
|
186
|
+
except (KeyError, TypeError, ValueError):
|
|
187
|
+
self.channels, components = 1, 1
|
|
188
|
+
if components % self.channels:
|
|
189
|
+
raise UnsupportedFormatError(f"{self.path!r}: {components} components "
|
|
190
|
+
f"for {self.channels} channels")
|
|
191
|
+
self.per_channel = components // self.channels
|
|
192
|
+
# RGB channels keep their samples (s); otherwise every component is a channel
|
|
193
|
+
self.samples = 3 if self.per_channel == 3 else 1
|
|
194
|
+
self.c = self.channels if self.samples == 3 else components
|
|
195
|
+
self.loops = self._loops()
|
|
196
|
+
self.stored_frames = len(self.boxes.get(b"LUNK", [])) // max(1, self.channels)
|
|
197
|
+
|
|
198
|
+
def _loops(self) -> list:
|
|
199
|
+
"""[(axis, count)] outermost first, loops of one element left out."""
|
|
200
|
+
raw = self._xml(b"AIM1") or self._xml(b"AIMD")
|
|
201
|
+
raw.pop("UnknownData", None)
|
|
202
|
+
while len(raw) == 1:
|
|
203
|
+
_, raw = raw.popitem()
|
|
204
|
+
if isinstance(raw, dict):
|
|
205
|
+
raw.pop("UnknownData", None)
|
|
206
|
+
description = ""
|
|
207
|
+
for item in self._xml(b"TINF").get("TextInfoItem", []) or []:
|
|
208
|
+
if isinstance(item, dict) and str(item.get("Text", "")).startswith("Metadata:"):
|
|
209
|
+
description = item["Text"]
|
|
210
|
+
described = _described_sizes(description)
|
|
211
|
+
levels = []
|
|
212
|
+
if "LoopNo00" in raw: # old style: numbered loops
|
|
213
|
+
levels = [v for k, v in raw.items() if k != "Version"]
|
|
214
|
+
else:
|
|
215
|
+
level = raw
|
|
216
|
+
while level:
|
|
217
|
+
if len(level) == 1:
|
|
218
|
+
level = next(iter(level.values()))
|
|
219
|
+
if level and isinstance(level, list):
|
|
220
|
+
level = level[0]
|
|
221
|
+
if not isinstance(level, dict):
|
|
222
|
+
break
|
|
223
|
+
levels.append(level)
|
|
224
|
+
level = level.get("NextLevelEx")
|
|
225
|
+
loops = []
|
|
226
|
+
for level in levels:
|
|
227
|
+
kind = level.get("Type")
|
|
228
|
+
if kind == 6 or kind is None: # channels: in the frame
|
|
229
|
+
continue
|
|
230
|
+
if kind not in LOOP_AXES:
|
|
231
|
+
raise UnsupportedFormatError(f"{self.path!r}: legacy ND2 loop type {kind}")
|
|
232
|
+
params = level.get("LoopPars", {})
|
|
233
|
+
axis = LOOP_AXES[kind]
|
|
234
|
+
if axis == "T":
|
|
235
|
+
periods = list((params.get("Period") or {}).values())
|
|
236
|
+
count = described.get("T")
|
|
237
|
+
if count is None or not any(p.get("Count") == count for p in periods):
|
|
238
|
+
count = periods[0]["Count"] if periods else params.get("Count", 1)
|
|
239
|
+
elif axis == "P":
|
|
240
|
+
count = described.get("P") or params.get("Count", 1)
|
|
241
|
+
else:
|
|
242
|
+
count = params.get("Count", 1)
|
|
243
|
+
if int(count) > 1:
|
|
244
|
+
loops.append((axis, int(count)))
|
|
245
|
+
axes = [a for a, _ in loops]
|
|
246
|
+
if len(set(axes)) != len(axes):
|
|
247
|
+
raise UnsupportedFormatError(f"{self.path!r}: repeated experiment loops {axes}")
|
|
248
|
+
return loops
|
|
249
|
+
|
|
250
|
+
def size(self, axis: str) -> int:
|
|
251
|
+
return next((n for a, n in self.loops if a == axis), 1)
|
|
252
|
+
|
|
253
|
+
def frame_number(self, t: int, p: int, z: int) -> int:
|
|
254
|
+
index = {"T": t, "P": p, "Z": z}
|
|
255
|
+
number = 0
|
|
256
|
+
for axis, count in self.loops:
|
|
257
|
+
number = number * count + index[axis]
|
|
258
|
+
return number
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class _LegacyNd2Image(Image):
|
|
262
|
+
"""One XY position: axes 'tczyx' (+ 's' for RGB)."""
|
|
263
|
+
|
|
264
|
+
def __init__(self, nd2: LegacyNd2Parser, position: int):
|
|
265
|
+
self._f = nd2
|
|
266
|
+
self._p = position
|
|
267
|
+
self._rgb = nd2.samples > 1
|
|
268
|
+
shape = (nd2.size("T"), nd2.c, nd2.size("Z"), nd2.height, nd2.width)
|
|
269
|
+
axes = "tczyx"
|
|
270
|
+
if self._rgb:
|
|
271
|
+
shape, axes = shape + (nd2.samples,), axes + "s"
|
|
272
|
+
self.decode_cache = DecodeCache(DECODE_CACHE_BYTES)
|
|
273
|
+
self._closed = False
|
|
274
|
+
super().__init__(position, axes, shape, nd2.dtype,
|
|
275
|
+
name=f"{os.path.basename(nd2.path)} position {position}",
|
|
276
|
+
read_unit=(1, 1, 1, nd2.height, nd2.width)
|
|
277
|
+
+ ((nd2.samples,) if self._rgb else ()))
|
|
278
|
+
|
|
279
|
+
def _read_block(self, block):
|
|
280
|
+
if self._closed:
|
|
281
|
+
raise ValueError("this ND2 image has been closed: its file was closed "
|
|
282
|
+
"(File.close() or the end of a with block)")
|
|
283
|
+
f = self._f
|
|
284
|
+
ts, cs, zs, ys, xs = block[:5]
|
|
285
|
+
ss = block[5] if self._rgb else slice(0, 1)
|
|
286
|
+
out = np.empty(tuple(s.stop - s.start for s in block), dtype=self.dtype)
|
|
287
|
+
view = out if self._rgb else out[..., None] # (t, c, z, y, x, s)
|
|
288
|
+
tasks = []
|
|
289
|
+
for t, z in itertools.product(range(ts.start, ts.stop), range(zs.start, zs.stop)):
|
|
290
|
+
number = f.frame_number(t, self._p, z)
|
|
291
|
+
for c in range(cs.start, cs.stop):
|
|
292
|
+
# a channel is a codestream (RGB: its 3 samples), or one component of it
|
|
293
|
+
stream, component = (c, None) if f.per_channel in (1, 3) \
|
|
294
|
+
else divmod(c, f.per_channel)
|
|
295
|
+
dst = view[t - ts.start, c - cs.start, z - zs.start]
|
|
296
|
+
tasks.append(lambda a=(number, stream, component, ys, xs, ss, dst):
|
|
297
|
+
self._plane_into(*a))
|
|
298
|
+
return out, _pool.run(tasks)
|
|
299
|
+
|
|
300
|
+
def _read_metadata(self):
|
|
301
|
+
from ._nd2_metadata import legacy_metadata
|
|
302
|
+
frame = self._f._xml(b"VIMD", self._f.frame_number(0, self._p, 0)) or self._f._xml(b"VIMD", 0)
|
|
303
|
+
return legacy_metadata(frame, dict(zip(self.axes, self.shape)))
|
|
304
|
+
|
|
305
|
+
def _plane_into(self, number, stream, component, ys, xs, ss, dst) -> int:
|
|
306
|
+
f = self._f
|
|
307
|
+
key = (number, stream)
|
|
308
|
+
plane, read = self.decode_cache.get(key), 0
|
|
309
|
+
if plane is None:
|
|
310
|
+
index = number * f.channels + stream
|
|
311
|
+
if number >= f.stored_frames or index >= len(f.boxes.get(b"LUNK", [])):
|
|
312
|
+
dst[...] = 0 # never acquired
|
|
313
|
+
return 0
|
|
314
|
+
nbytes = f.height * f.width * f.per_channel * f.dtype.itemsize
|
|
315
|
+
if _memory.should_stream(nbytes): # too large to cache: the region alone
|
|
316
|
+
return self._area_into(f._box(b"LUNK", index, f.handles.get()),
|
|
317
|
+
component, ys, xs, ss, dst)
|
|
318
|
+
data = f._box(b"LUNK", index, f.handles.get())
|
|
319
|
+
plane = np.empty((f.height, f.width, f.per_channel), f.dtype)
|
|
320
|
+
with _memory.reserve(nbytes, f"{f.path!r}: a {f.height} x {f.width} JPEG 2000 frame"):
|
|
321
|
+
got = _codecs.jpeg2k_decode_into(data, plane)
|
|
322
|
+
if (got[1], got[0], got[2]) != plane.shape:
|
|
323
|
+
raise CorruptFileError(
|
|
324
|
+
f"{f.path!r}: frame {number} decodes to {got[1]} x {got[0]} x {got[2]}, "
|
|
325
|
+
f"the header says {plane.shape}")
|
|
326
|
+
self.decode_cache.put(key, plane)
|
|
327
|
+
read = plane.size
|
|
328
|
+
if component is not None:
|
|
329
|
+
dst[..., 0] = plane[ys, xs, component]
|
|
330
|
+
else:
|
|
331
|
+
dst[...] = plane[ys, xs][..., ss] if self._rgb else plane[ys, xs, :1]
|
|
332
|
+
return read
|
|
333
|
+
|
|
334
|
+
def _area_into(self, data, component, ys, xs, ss, dst) -> int:
|
|
335
|
+
"""Rows *ys* and columns *xs* of a frame too large to cache, decoded
|
|
336
|
+
alone (OpenJPEG decodes only the code-blocks covering them)."""
|
|
337
|
+
f = self._f
|
|
338
|
+
row_bytes = (xs.stop - xs.start) * f.per_channel * f.dtype.itemsize
|
|
339
|
+
per_band = max(1, min(8 << 20, _memory.memory_limit() // 4) // row_bytes)
|
|
340
|
+
for a in range(ys.start, ys.stop, per_band): # bands within the budget
|
|
341
|
+
b = min(ys.stop, a + per_band)
|
|
342
|
+
area = np.empty((b - a, xs.stop - xs.start, f.per_channel), f.dtype)
|
|
343
|
+
with _memory.reserve(area.nbytes, f"{f.path!r}: part of a JPEG 2000 frame"):
|
|
344
|
+
got = _codecs.jpeg2k_decode_area_into(data, area, xs.start, a, xs.stop, b)
|
|
345
|
+
if (got[1], got[0], got[2]) != area.shape:
|
|
346
|
+
raise CorruptFileError(f"{f.path!r}: JPEG 2000 area decodes to "
|
|
347
|
+
f"{got[1]} x {got[0]} x {got[2]}, expected {area.shape}")
|
|
348
|
+
band = dst[a - ys.start:b - ys.start]
|
|
349
|
+
if component is not None:
|
|
350
|
+
band[..., 0] = area[..., component]
|
|
351
|
+
else:
|
|
352
|
+
band[...] = area[..., ss] if self._rgb else area[..., :1]
|
|
353
|
+
return (ys.stop - ys.start) * (xs.stop - xs.start) * f.per_channel
|
|
354
|
+
|
|
355
|
+
def close(self) -> None:
|
|
356
|
+
self._closed = True
|
|
357
|
+
self.decode_cache.clear()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class _LegacyNd2File(File):
|
|
361
|
+
backend = "nd2"
|
|
362
|
+
|
|
363
|
+
def __init__(self, path: str):
|
|
364
|
+
self._nd2 = nd2 = LegacyNd2Parser(path)
|
|
365
|
+
self.format = "nd2-legacy" # format versions 1 / 2 (JPEG 2000)
|
|
366
|
+
super().__init__(path, [_LegacyNd2Image(nd2, p) for p in range(nd2.size("P"))])
|
|
367
|
+
|
|
368
|
+
def close(self) -> None:
|
|
369
|
+
super().close()
|
|
370
|
+
self._nd2.handles.close()
|