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