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,104 @@
1
+ """Backend selection: content first, file extension only as a fallback."""
2
+ from __future__ import annotations
3
+
4
+ import builtins
5
+ import os
6
+ from typing import Optional
7
+
8
+ from ._base import SAMPLED_INDICES, File
9
+ from ._errors import UnsupportedFormatError
10
+ from .backends import czi, ims, lif, mrc, nd2, oir, raster, tiff
11
+
12
+ #: Tried in order: magic numbers before MRC, which may also match by
13
+ #: extension alone. Each backend module provides ``NAME``,
14
+ #: ``sniff(head: bytes, path: str) -> bool`` and
15
+ #: ``open(path, tiles="separate", resolutions="base") -> File``.
16
+ BACKENDS = [tiff, czi, ims, nd2, oir, lif, raster, mrc]
17
+
18
+ #: Bytes read from the start of the file for sniffing.
19
+ HEAD_BYTES = 1024
20
+
21
+ #: mosaic tiles as separate images (default) or composed, where the format
22
+ #: defines how (CZI); see docs/AXES.md
23
+ TILE_POLICIES = ("separate", "stitched")
24
+
25
+ #: resolution levels: full resolution only (default), or every level as its
26
+ #: own image indexed by "resolution" (image.resolutions gives a pyramid either way)
27
+ RESOLUTION_POLICIES = ("base", "all")
28
+
29
+
30
+ def open(path, backend: Optional[str] = None, *, tiles: str = "separate", # noqa: A001
31
+ resolutions: str = "base", rename: Optional[dict] = None,
32
+ axes: Optional[str] = None, samples: str = "keep", squeeze: bool = False) -> File:
33
+ """Open *path* with the backend that recognises it (or the one named).
34
+
35
+ Each image of the file is one spatial frame with axes ``t c z y x`` (+ ``s``),
36
+ and an index saying which: scene, tile, rotation, view, and the
37
+ dimensions sampled at the same points (illumination, phase, block,
38
+ emission, excitation, lifetime, sequence). *tiles*: ``"separate"`` gives
39
+ each mosaic tile as its own image; ``"stitched"`` composes a scene's
40
+ tiles, for formats that define how (CZI), and is an error for tiled files
41
+ of other formats.
42
+
43
+ *rename* makes an index an array axis, for files that do not say what a
44
+ dimension is: ``rename={"sequence": "z"}`` reads a plain multi-page TIFF
45
+ as one z-stack. The target is ``t``, ``c`` or ``z``; a format that
46
+ cannot do it for that index raises.
47
+
48
+ *axes* (``"canonical"``, or letters such as ``"tczyx"``), *samples* and
49
+ *squeeze* present every image as ``Image.as_axes`` does. Each image's
50
+ ``.source`` is then its native image."""
51
+ if tiles not in TILE_POLICIES:
52
+ raise ValueError(f"tiles={tiles!r}: one of {TILE_POLICIES}")
53
+ if resolutions not in RESOLUTION_POLICIES:
54
+ raise ValueError(f"resolutions={resolutions!r}: one of {RESOLUTION_POLICIES}")
55
+ rename = _check_rename(rename)
56
+ image = _open(os.fspath(path), backend, tiles, resolutions, rename)
57
+ left = sorted({name for index in image.indices for name in index if name in rename})
58
+ if left:
59
+ image.close()
60
+ raise UnsupportedFormatError(
61
+ f"rename: {path!r}: the {image.backend} backend cannot make {left} an axis")
62
+ if axes is not None or samples != "keep" or squeeze:
63
+ from ._axes import AxesImage
64
+ try:
65
+ image.images = [AxesImage(s, axes, samples=samples, squeeze=squeeze)
66
+ for s in image.images]
67
+ except BaseException:
68
+ image.close()
69
+ raise
70
+ return image
71
+
72
+
73
+ def _check_rename(rename: Optional[dict]) -> dict:
74
+ rename = {str(k).lower(): str(v).lower() for k, v in (rename or {}).items()}
75
+ for name, axis in rename.items():
76
+ if name not in SAMPLED_INDICES:
77
+ raise ValueError(f"rename: {name!r} is not an index that can become an axis "
78
+ f"(those are {SAMPLED_INDICES})")
79
+ if axis not in ("t", "c", "z"):
80
+ raise ValueError(f"rename {name!r}: {axis!r} is not 't', 'c' or 'z'")
81
+ if len(set(rename.values())) != len(rename):
82
+ raise ValueError(f"rename {rename}: two indices onto one axis")
83
+ return rename
84
+
85
+
86
+ def _open(path: str, backend: Optional[str], tiles: str, resolutions: str,
87
+ rename: dict) -> File:
88
+ def call(module):
89
+ if rename and getattr(module, "RENAMES", False):
90
+ return module.open(path, tiles, resolutions, rename=rename)
91
+ return module.open(path, tiles, resolutions)
92
+
93
+ if backend is not None:
94
+ for module in BACKENDS:
95
+ if module.NAME == backend:
96
+ return call(module)
97
+ raise UnsupportedFormatError(
98
+ f"unknown backend {backend!r}; available: {[m.NAME for m in BACKENDS]}")
99
+ with builtins.open(path, "rb") as fh:
100
+ head = fh.read(HEAD_BYTES)
101
+ for module in BACKENDS:
102
+ if module.sniff(head, path):
103
+ return call(module)
104
+ raise UnsupportedFormatError(f"no micro-reader backend recognises {path!r}")
@@ -0,0 +1,317 @@
1
+ """Streaming decode of compressed blocks too large to decode whole
2
+ (``_memory``; docs/PLAN.md, "memory-bounded reads of huge compressed blocks").
3
+
4
+ A block is decoded from its start, keeping only the bytes a read asks for;
5
+ the compressed bytes are read from the file in small pieces. Each decoder
6
+ keeps its place, and reads are handed the decoder of their block that is
7
+ closest before their start, so regions read top to bottom (as eubi-bridge's
8
+ writer reads) continue where the last one stopped: reading a whole plane
9
+ stays linear. Several reads of one block at once each get a decoder of
10
+ their own.
11
+
12
+ Checkpoints (deflate / zlib): the decoders of a block share an index of the
13
+ inflater's state every ``every`` decoded bytes (about 1/128 of the block, at
14
+ least 1 MB; ~43 KB each), so a read before a decoder's place -- a viewer
15
+ jumping around -- resumes from the nearest checkpoint instead of starting
16
+ the block again. Indexes of the ``MAX_INDEXES`` most recent blocks are kept.
17
+
18
+ Native: ``micro_reader_codecs.StreamDecoder`` (zlib / deflate, LZW, zstd,
19
+ PackBits, none). Reference, in Python: zlib / deflate (stdlib), LZMA
20
+ (stdlib), zstd (Python 3.14 or ``zstandard``), PackBits, none -- LZMA
21
+ always, the others when the native package is missing.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import threading
26
+ import zlib
27
+ from collections import OrderedDict
28
+
29
+ from . import _codecs
30
+ from ._errors import CorruptFileError
31
+
32
+ #: codecs that can be streamed
33
+ STREAMABLE = ("zlib", "deflate", "lzw", "zstd", "packbits", "lzma", "none", "png")
34
+ NATIVE = ("zlib", "deflate", "lzw", "zstd", "packbits", "none", "png")
35
+ #: decoders kept for later reads (each holds a file handle and its window)
36
+ MAX_IDLE = 32
37
+ #: blocks whose checkpoint indexes are kept; checkpoints per block, spacing
38
+ MAX_INDEXES = 16
39
+ MAX_CHECKPOINTS = 128
40
+ MIN_CHECKPOINT_EVERY = 1 << 20
41
+ CHECKPOINTED = ("zlib", "deflate")
42
+ _PIECE = 1 << 16
43
+
44
+
45
+ def streamable(codec: str) -> bool:
46
+ if codec not in STREAMABLE:
47
+ return False
48
+ if codec in NATIVE and _native_stream() is not None:
49
+ return True
50
+ return codec in ("zlib", "deflate", "lzma", "packbits", "none") or \
51
+ (codec == "zstd" and _reference_zstd_stream() is not None)
52
+
53
+
54
+ def _native_stream():
55
+ native = _codecs._native
56
+ return getattr(native, "StreamDecoder", None) if native is not None else None
57
+
58
+
59
+ def _reference_zstd_stream():
60
+ try:
61
+ from compression import zstd # Python >= 3.14
62
+ return lambda: zstd.ZstdDecompressor()
63
+ except ImportError:
64
+ pass
65
+ try:
66
+ import zstandard
67
+ return lambda: zstandard.ZstdDecompressor().decompressobj()
68
+ except ImportError:
69
+ return None
70
+
71
+
72
+ class _PackBits:
73
+ """PackBits as a decompressor object (``decompress(data)``)."""
74
+
75
+ def __init__(self):
76
+ self.pending = b""
77
+
78
+ def decompress(self, data: bytes) -> bytes:
79
+ data, out, i = self.pending + data, bytearray(), 0
80
+ while i < len(data):
81
+ n = data[i] - 256 if data[i] > 127 else data[i]
82
+ if n >= 0:
83
+ if i + 1 + n + 1 > len(data):
84
+ break
85
+ out += data[i + 1:i + 2 + n]
86
+ i += n + 2
87
+ elif n != -128:
88
+ if i + 1 >= len(data):
89
+ break
90
+ out += bytes([data[i + 1]]) * (1 - n)
91
+ i += 2
92
+ else:
93
+ i += 1
94
+ self.pending = data[i:]
95
+ return bytes(out)
96
+
97
+
98
+ class _ReferenceIndex:
99
+ """Checkpoints of one zlib / deflate block for the reference decoders:
100
+ (decoded position, compressed bytes consumed, decompressor copy)."""
101
+
102
+ def __init__(self, every: int, max: int = MAX_CHECKPOINTS):
103
+ self.every, self.max, self.points = every, max, []
104
+ self._lock = threading.Lock()
105
+
106
+ def best(self, start: int):
107
+ with self._lock:
108
+ before = [p for p in self.points if p[0] <= start]
109
+ return max(before, key=lambda p: p[0]) if before else None
110
+
111
+ def record(self, position: int, consumed: int, decompressor) -> None:
112
+ with self._lock:
113
+ if len(self.points) >= self.max or \
114
+ any(abs(p[0] - position) < self.every for p in self.points):
115
+ return
116
+ self.points.append((position, consumed, decompressor.copy()))
117
+
118
+ def __len__(self):
119
+ return len(self.points)
120
+
121
+
122
+ class _ReferenceDecoder:
123
+ """The native decoder's interface, decoded with Python's decompressor objects."""
124
+
125
+ def __init__(self, path: str, offset: int, length: int, codec: str, index=None):
126
+ self.path, self.offset, self.length, self.codec = path, offset, length, codec
127
+ self.position, self.restarts, self.resumes = 0, 0, 0
128
+ self._index = index if codec in CHECKPOINTED else None
129
+ self._fh = None
130
+ self._lock = threading.Lock()
131
+
132
+ def _resume(self, point) -> None:
133
+ position, consumed, decompressor = point
134
+ if self._fh is not None:
135
+ self._fh.close()
136
+ self._fh = open(self.path, "rb") # noqa: SIM115 - kept open
137
+ self._fh.seek(self.offset + consumed)
138
+ self._left = self.length - consumed
139
+ self._pending = b""
140
+ self.position = position
141
+ self._decompressor = decompressor.copy()
142
+ self.resumes += 1
143
+
144
+ def _start(self):
145
+ import lzma
146
+ if self._fh is not None:
147
+ self.restarts += 1
148
+ self._fh.close()
149
+ self._fh = open(self.path, "rb") # noqa: SIM115 - kept open
150
+ self._fh.seek(self.offset)
151
+ self._left = self.length
152
+ self._pending = b""
153
+ self.position = 0
154
+ self._decompressor = {
155
+ "zlib": lambda: zlib.decompressobj(),
156
+ "deflate": lambda: zlib.decompressobj(-15),
157
+ "lzma": lambda: lzma.LZMADecompressor(),
158
+ "zstd": _reference_zstd_stream(),
159
+ "packbits": _PackBits,
160
+ "none": lambda: None,
161
+ }[self.codec]()
162
+
163
+ def _more(self) -> bytes:
164
+ """The next decoded bytes; b"" at the end of the block."""
165
+ while True:
166
+ if not self._left:
167
+ return b""
168
+ data = self._fh.read(min(_PIECE, self._left))
169
+ self._left -= len(data)
170
+ if not data:
171
+ self._left = 0
172
+ return b""
173
+ if self._decompressor is None:
174
+ return data
175
+ try:
176
+ out = self._decompressor.decompress(data)
177
+ except Exception as exc: # noqa: BLE001 - codec specific
178
+ raise CorruptFileError(f"{self.path!r}: corrupt {self.codec} data: {exc}") from exc
179
+ if out:
180
+ return out
181
+
182
+ def read_into(self, start: int, out) -> int:
183
+ view = memoryview(out).cast("B")
184
+ with self._lock:
185
+ behind = self._fh is None or start < self.position
186
+ point = self._index.best(start) if self._index is not None else None
187
+ if point is not None and (behind or point[0] > self.position):
188
+ self._resume(point)
189
+ elif behind:
190
+ self._start()
191
+ written = 0
192
+ while written < len(view):
193
+ if not self._pending:
194
+ if self._index is not None: # all decoded so far is delivered
195
+ self._index.record(self.position, self.length - self._left,
196
+ self._decompressor)
197
+ self._pending = self._more()
198
+ if not self._pending:
199
+ break
200
+ skip = start + written - self.position
201
+ if skip >= len(self._pending): # all before the start
202
+ self.position += len(self._pending)
203
+ self._pending = b""
204
+ continue
205
+ if skip > 0:
206
+ self.position += skip
207
+ self._pending = self._pending[skip:]
208
+ n = min(len(view) - written, len(self._pending))
209
+ view[written:written + n] = self._pending[:n]
210
+ self._pending = self._pending[n:]
211
+ self.position += n
212
+ written += n
213
+ return written
214
+
215
+ def close(self) -> None:
216
+ with self._lock:
217
+ if self._fh is not None:
218
+ self._fh.close()
219
+ self._fh = None
220
+
221
+ def __del__(self):
222
+ if getattr(self, "_fh", None) is not None:
223
+ self._fh.close()
224
+
225
+
226
+ _lock = threading.Lock()
227
+ #: (path, offset, codec) -> decoders not in use; least recently used first
228
+ _idle: "OrderedDict[tuple, list]" = OrderedDict()
229
+ #: (path, offset, codec) -> the block's checkpoint index; least recently used first
230
+ _indexes: "OrderedDict[tuple, object]" = OrderedDict()
231
+
232
+
233
+ def _index(key: tuple, size) -> object:
234
+ """The checkpoint index of block *key* (zlib / deflate), created on first use."""
235
+ if key[2] not in CHECKPOINTED:
236
+ return None
237
+ with _lock:
238
+ index = _indexes.get(key)
239
+ if index is None:
240
+ every = max(MIN_CHECKPOINT_EVERY, int(size or 0) // MAX_CHECKPOINTS)
241
+ native = _codecs._native
242
+ index = (native.BlockIndex(every, MAX_CHECKPOINTS)
243
+ if native is not None and hasattr(native, "BlockIndex")
244
+ else _ReferenceIndex(every))
245
+ _indexes[key] = index
246
+ while len(_indexes) > MAX_INDEXES:
247
+ _indexes.popitem(last=False)
248
+ else:
249
+ _indexes.move_to_end(key)
250
+ return index
251
+
252
+
253
+ def _new(path: str, offset: int, length: int, codec: str, index=None):
254
+ native = _native_stream()
255
+ if native is not None and codec in NATIVE:
256
+ if index is not None and not isinstance(index, _ReferenceIndex):
257
+ return native(path, offset, length, codec, index)
258
+ return native(path, offset, length, codec)
259
+ return _ReferenceDecoder(path, offset, length, codec,
260
+ index if isinstance(index, _ReferenceIndex) else None)
261
+
262
+
263
+ def _acquire(key: tuple, length: int, start: int, size=None):
264
+ with _lock:
265
+ decoders = _idle.get(key, [])
266
+ before = [d for d in decoders if d.position <= start]
267
+ if before:
268
+ decoder = max(before, key=lambda d: d.position)
269
+ decoders.remove(decoder)
270
+ if not decoders:
271
+ _idle.pop(key, None)
272
+ return decoder
273
+ if decoders: # all past *start*: one resumes from a checkpoint
274
+ decoder = decoders.pop(0)
275
+ if not decoders:
276
+ _idle.pop(key, None)
277
+ return decoder
278
+ return _new(key[0], key[1], length, key[2], _index(key, size))
279
+
280
+
281
+ def _release(key: tuple, decoder) -> None:
282
+ with _lock:
283
+ _idle.setdefault(key, []).append(decoder)
284
+ _idle.move_to_end(key)
285
+ total = sum(len(v) for v in _idle.values())
286
+ while total > MAX_IDLE:
287
+ oldest = next(iter(_idle))
288
+ _idle[oldest].pop(0)
289
+ if not _idle[oldest]:
290
+ del _idle[oldest]
291
+ total -= 1
292
+
293
+
294
+ def read(path: str, offset: int, length: int, codec: str, start: int, out,
295
+ size: int = None) -> int:
296
+ """Decoded bytes *start*... of the block (*length* compressed bytes at
297
+ *offset* of *path*, *codec*; *size* decoded bytes, if known) into *out*;
298
+ returns the bytes written (fewer at the end of the block)."""
299
+ key = (str(path), int(offset), codec)
300
+ decoder = _acquire(key, int(length), int(start), size)
301
+ try:
302
+ try:
303
+ return decoder.read_into(int(start), out)
304
+ except ValueError as exc:
305
+ raise CorruptFileError(f"{path!r}: corrupt {codec} data: {exc}") from exc
306
+ finally:
307
+ _release(key, decoder)
308
+
309
+
310
+ def forget(path: str) -> None:
311
+ """Close and drop the decoders of *path* (its file was closed)."""
312
+ with _lock:
313
+ dropped = [d for key in [k for k in _idle if k[0] == str(path)] for d in _idle.pop(key)]
314
+ for key in [k for k in _indexes if k[0] == str(path)]:
315
+ del _indexes[key]
316
+ for decoder in dropped:
317
+ getattr(decoder, "close", lambda: None)()
@@ -0,0 +1 @@
1
+ """Format backends. Each module provides NAME, sniff(head, path) and open(path)."""
@@ -0,0 +1,188 @@
1
+ """Metadata of CZI images (docs/METADATA.md), from the file's XML metadata
2
+ segment (``ZISRAWMETADATA``) and the first subblock's own metadata.
3
+
4
+ - Pixel sizes: ``Metadata/Scaling/Items/Distance`` X / Y / Z, stored in
5
+ metres; given in the file's display unit (``DefaultUnitFormat``, normally
6
+ µm) when OME-Zarr has a name for it, else micrometres.
7
+ - Time step: ``Information/Image/Dimensions/T/Positions/Interval/Increment``
8
+ (seconds).
9
+ - Channels: ``Information/Image/Dimensions/Channels/Channel`` (Name, Color,
10
+ Excitation / EmissionWavelength in nm), colours missing there from
11
+ ``DisplaySetting/Channels``.
12
+ - Stage position: of the centre of the image's first pixel (docs/METADATA.md),
13
+ from ``StageXPosition`` / ``StageYPosition`` (µm) of the image's first
14
+ subblock, which Zeiss records for the centre of that field of view
15
+ (Bio-Formats reports it as is: the centre); files without it: the
16
+ scene's ``Positions/Position`` in the XML, taken as the image's centre.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import struct
21
+ from dataclasses import replace
22
+ import xml.etree.ElementTree as ET
23
+
24
+ from .._metadata import Channel, Metadata, Quantity, color_hex, quantity, unit_name
25
+
26
+
27
+ def read_xml(fh):
28
+ """The root of the XML metadata segment of the CZI open as *fh*, or
29
+ None (no segment, unreadable XML)."""
30
+ fh.seek(32)
31
+ head = fh.read(80)
32
+ metadata_position = struct.unpack_from("<iiii16s16siqqiq", head)[8]
33
+ if not metadata_position:
34
+ return None
35
+ fh.seek(metadata_position)
36
+ seg = fh.read(32 + 8)
37
+ if seg[:14] != b"ZISRAWMETADATA":
38
+ return None
39
+ size, _attachment = struct.unpack_from("<ii", seg, 32)
40
+ fh.seek(metadata_position + 32 + 256)
41
+ try:
42
+ return ET.fromstring(fh.read(size))
43
+ except ET.ParseError:
44
+ return None
45
+
46
+
47
+ def subblock_tags(fh, position: int) -> dict:
48
+ """The ``Tags`` of the subblock segment at *position*: {tag: text}."""
49
+ fh.seek(position)
50
+ head = fh.read(32 + 48)
51
+ if head[:14] != b"ZISRAWSUBBLOCK":
52
+ return {}
53
+ metadata_size, = struct.unpack_from("<i", head, 32)
54
+ ndims, = struct.unpack_from("<i", head, 32 + 44)
55
+ if metadata_size <= 0:
56
+ return {}
57
+ fh.seek(position + 32 + 16 + max(240, 32 + 20 * ndims))
58
+ try:
59
+ root = ET.fromstring(fh.read(metadata_size).rstrip(b"\x00"))
60
+ except ET.ParseError:
61
+ return {}
62
+ tags = root.find("Tags")
63
+ return {} if tags is None else {e.tag: (e.text or "").strip() for e in tags}
64
+
65
+
66
+ def pixel_sizes(root) -> dict:
67
+ sizes = {}
68
+ if root is None:
69
+ return sizes
70
+ for d in root.findall("Metadata/Scaling/Items/Distance"):
71
+ axis = (d.get("Id") or "").lower()
72
+ if axis not in ("x", "y", "z"):
73
+ continue
74
+ q = quantity(d.findtext("Value"), "meter")
75
+ if q is None:
76
+ continue
77
+ shown = unit_name(d.findtext("DefaultUnitFormat"))
78
+ # the display unit when it is a length OME-Zarr names (and not
79
+ # larger than a millimetre: ``quantity``), else µm
80
+ try:
81
+ shown_q = q.to(shown) if shown else q
82
+ except KeyError:
83
+ shown_q = q
84
+ sizes[axis] = quantity(shown_q.value, shown_q.unit)
85
+ increment = root.findtext("Metadata/Information/Image/Dimensions/T/Positions/Interval/Increment")
86
+ q = quantity(increment, "second")
87
+ if q:
88
+ sizes["t"] = q
89
+ return sizes
90
+
91
+
92
+ def channels(root, c_start: int, n: int) -> tuple:
93
+ """Channels with indices c_start .. c_start + n - 1 of the file."""
94
+ found = []
95
+ display = []
96
+ if root is not None:
97
+ found = root.findall("Metadata/Information/Image/Dimensions/Channels/Channel")
98
+ display = root.findall("Metadata/DisplaySetting/Channels/Channel")
99
+ display_color = {}
100
+ for k, d in enumerate(display):
101
+ color = color_hex(d.findtext("Color"))
102
+ for name in (d.get("Name"), d.get("Id"), k):
103
+ display_color.setdefault(name, color)
104
+ out = []
105
+ for k in range(c_start, c_start + max(1, n)):
106
+ if k >= len(found):
107
+ out.append(Channel())
108
+ continue
109
+ c = found[k]
110
+ name = c.get("Name") or None
111
+ color = color_hex(c.findtext("Color"))
112
+ if color is None:
113
+ color = display_color.get(name) or display_color.get(c.get("Id"))
114
+ if color is None and len(display) == len(found):
115
+ color = display_color.get(k)
116
+ out.append(Channel(
117
+ name=name, color=color,
118
+ emission_wavelength=quantity(c.findtext("EmissionWavelength"), "nanometer"),
119
+ excitation_wavelength=quantity(c.findtext("ExcitationWavelength"), "nanometer")))
120
+ return tuple(out)
121
+
122
+
123
+ def scene_position(root, scene: int) -> dict:
124
+ """The scene's stage position in the XML (µm): ``Positions/Position``."""
125
+ if root is None:
126
+ return {}
127
+ for e in root.findall("Metadata/Information/Image/Dimensions/S/Scenes/Scene"):
128
+ if e.get("Index") == str(scene):
129
+ p = e.find("Positions/Position")
130
+ if p is not None:
131
+ out = {a: quantity(p.get(a.upper()), "micrometer", positive=False)
132
+ for a in ("x", "y")}
133
+ return {a: q for a, q in out.items() if q}
134
+ return {}
135
+
136
+
137
+ def position_from_tags(tags: dict) -> dict:
138
+ out = {}
139
+ for axis in ("x", "y"):
140
+ q = quantity(tags.get(f"Stage{axis.upper()}Position"), "micrometer", positive=False)
141
+ if q:
142
+ out[axis] = q
143
+ return out
144
+
145
+
146
+ def first_pixel_position(image, centre: dict, s, sizes: dict) -> dict:
147
+ """Stage position of the centre of *image*'s first pixel, from *centre*:
148
+ the stage position of the centre of subblock *s* (Zeiss records where
149
+ the field of view's centre was). Pixel coordinates are one frame for
150
+ the whole file, so the offset between the two frames found at *s* holds
151
+ for the image's corner too."""
152
+ out = {}
153
+ origin = dict(zip("xy", image.origin))
154
+ extent = {"x": s.width, "y": s.height}
155
+ start = {"x": s.x, "y": s.y}
156
+ for axis, q in centre.items():
157
+ size = sizes.get(axis)
158
+ if size is None:
159
+ continue
160
+ step = size.to(q.unit).value
161
+ offset = q.value - (start[axis] + extent[axis] / 2) * step
162
+ out[axis] = Quantity(offset + (origin[axis] + 0.5) * step, q.unit)
163
+ return out
164
+
165
+
166
+ def czi_metadata(image, fh, root) -> Metadata:
167
+ """*image*'s Metadata; *fh* an open handle of its file, *root* the
168
+ file's XML metadata (or None)."""
169
+ axes = image.axes
170
+ n_c = image.shape[axes.index("c")] if "c" in axes else 1
171
+ sizes = pixel_sizes(root)
172
+ # the first subblock drawn of the first plane
173
+ first = image._planes[min(image._planes)].subblocks[0] if image._planes else None
174
+ position = {}
175
+ if first is not None and first.position >= 0:
176
+ centre = position_from_tags(subblock_tags(fh, first.position))
177
+ position = first_pixel_position(image, centre, first, sizes)
178
+ if not position and first is not None:
179
+ # no stage position per subblock: the scene's, taken as the centre of
180
+ # the whole scene (all its level-0 tiles; as Bio-Formats places it)
181
+ tiles = [e for e in image._czi._level0 if e.scene == first.scene]
182
+ x0, y0 = min(e.x for e in tiles), min(e.y for e in tiles)
183
+ x1 = max(e.x + e.width for e in tiles)
184
+ y1 = max(e.y + e.height for e in tiles)
185
+ whole = replace(first, x=x0, y=y0, width=x1 - x0, height=y1 - y0)
186
+ position = first_pixel_position(image, scene_position(root, first.scene), whole, sizes)
187
+ return Metadata(pixel_sizes=sizes, position=position,
188
+ channels=channels(root, image._czi._dim_starts.get("C", 0), n_c))