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,948 @@
1
+ """Zeiss CZI (ZISRAW), own reader, numpy and stdlib only.
2
+
3
+ Structure: segments with a 32-byte header (``ZISRAWFILE``, ``ZISRAWDIRECTORY``,
4
+ ``ZISRAWSUBBLOCK``, ...). The file header points to the subblock directory:
5
+ one entry per subblock with its pixel type, compression, pyramid type, file
6
+ position (64-bit: files beyond 4 GB work) and, per dimension, start index,
7
+ size and stored size. A subblock is a tile of one plane: X / Y give its
8
+ rectangle in the image's pixel coordinates, the other dimensions its plane
9
+ (T, C, Z, views V, phases H, illuminations I, rotations R, blocks B), S its
10
+ scene and M its mosaic index.
11
+
12
+ Images (docs/AXES.md): one per scene, mosaic tile, rotation, view,
13
+ illumination, phase and block, indexed by ``{"scene": S, "tile": M,
14
+ "rotation": R, "view": V, "illumination": I, "phase": H, "block": B}`` with
15
+ the file's own indices; an index is there only where the file has more than
16
+ one (tiles: more than one in some scene). ``tiles="stitched"`` composes each scene's
17
+ tiles instead, the way libCZI / pylibCZIrw compose them: the level-0
18
+ subblocks of a plane (not pyramid levels) are drawn in mosaic-index order,
19
+ later tiles on top, over a black background, inside the bounding box.
20
+ Uncompressed subblocks are read exactly: only the rows and columns of each
21
+ tile that fall into the requested rectangle. ``canvas()`` composes all
22
+ scenes.
23
+
24
+ Axes: ``t c z y x``, and ``s`` for RGB pixels -- samples in R, G, B (A)
25
+ order (CZI stores them B, G, R). Not yet: compressed subblocks (zstd: next step, native;
26
+ JPEG-XR / JPEG / LZW), multi-part files, complex pixel types.
27
+
28
+ The binary layout follows czifile (Christoph Gohlke, BSD-3,
29
+ https://github.com/cgohlke/czifile) and the published ZISRAW description;
30
+ nothing is taken from libCZI or aicspylibczi.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import builtins
35
+ import itertools
36
+ import os
37
+ import struct
38
+ import sys
39
+ import warnings
40
+ from dataclasses import dataclass, replace
41
+
42
+ import numpy as np
43
+
44
+ from .. import _codecs, _jpeg_restarts, _memory, _pool, _stream
45
+ from .._base import File, Image
46
+ from .._errors import CorruptFileError, UnsupportedFormatError
47
+ from .._io import DecodeCache, ThreadHandles
48
+
49
+ NAME = "czi"
50
+ MAGIC = b"ZISRAWFILE"
51
+ RAW_TASK_BYTES = 8 << 20 # scenes are read in row bands of this size
52
+ #: bytes worth skipping in one contiguous read rather than issuing one more
53
+ #: read (a seek + read from Python costs about as much as copying this much)
54
+ SEPARATE_READ_BYTES = 32 << 10
55
+ NATIVE_LITTLE = sys.byteorder == "little"
56
+
57
+ #: pixel type -> (dtype, samples)
58
+ PIXEL_TYPES = {0: ("<u1", 1), 1: ("<u2", 1), 2: ("<f4", 1), 3: ("<u1", 3), 4: ("<u2", 3),
59
+ 8: ("<f4", 3), 9: ("<u1", 4)}
60
+ #: plane dimensions: the axes, in the order they become axes (before y, x,
61
+ #: s), and the image indices (in index order)
62
+ AXIS_DIMS = "TCZ"
63
+ KEY_DIMS = {"rotation": "R", "view": "V", "illumination": "I", "phase": "H", "block": "B"}
64
+ PLANE_DIMS = AXIS_DIMS + "".join(KEY_DIMS.values())
65
+ TILE_POLICIES = ("separate", "stitched")
66
+ UNCOMPRESSED, JPEG, LZW, JPEG_LOSSLESS, JPEG_XR, ZSTD0, ZSTD1, CHUNKED, JPEG_XL = range(9)
67
+ COMPRESSIONS = {UNCOMPRESSED: "none", JPEG: "JPEG", LZW: "LZW",
68
+ JPEG_LOSSLESS: "JPEG lossless (undocumented; standard lossless JPEG assumed)",
69
+ JPEG_XR: "JPEG XR", ZSTD0: "zstd", ZSTD1: "zstd with header (hi-lo packing)",
70
+ CHUNKED: "chunked (experimental in libCZI)",
71
+ JPEG_XL: "JPEG XL (undocumented; a JPEG XL codestream assumed)"}
72
+ IMAGE_CODECS = {JPEG, JPEG_LOSSLESS, JPEG_XR, JPEG_XL}
73
+ DECODE_CACHE_BYTES = 64 << 20 # decoded compressed tiles kept for reuse
74
+ STREAM_PIECE_BYTES = 8 << 20 # rows of a streamed subblock decoded at a time
75
+
76
+
77
+ def sniff(head: bytes, path: str) -> bool:
78
+ return head[:10] == MAGIC
79
+
80
+
81
+ @dataclass
82
+ class Subblock:
83
+ plane: dict # dimension -> absolute start index (T, C, Z, ...)
84
+ x: int
85
+ y: int
86
+ width: int
87
+ height: int
88
+ scene: int
89
+ mosaic: int
90
+ order: int # position in the directory
91
+ position: int # file position of the subblock segment
92
+ compression: int
93
+ pixel_type: int
94
+ level0: bool = True # full resolution (not a pyramid level)
95
+ key: tuple = () # everything the entry says about the subblock except where it is
96
+ data_offset: int = 0 # pixel data, filled in when first read
97
+ data_size: int = 0 # bytes of (possibly compressed) pixel data
98
+ stored_width: int = 0 # pixels stored: smaller than width / height in a pyramid level
99
+ stored_height: int = 0
100
+
101
+
102
+ def _parse_entry(data, pos: int):
103
+ """A ``DV`` directory entry at *data[pos:]* -> (Subblock, entry size).
104
+
105
+ The same entry is stored in the directory and again inside the subblock
106
+ segment itself; ``key`` lets the two be compared.
107
+ """
108
+ (schema, pixel_type, position, part, compression, pyramid_type, _, _,
109
+ ndims) = struct.unpack_from("<2siqiiBB4si", data, pos)
110
+ if schema != b"DV":
111
+ raise UnsupportedFormatError(f"CZI directory entry schema {schema!r}")
112
+ if not 0 < ndims < 64:
113
+ raise CorruptFileError(f"CZI directory entry with {ndims} dimensions")
114
+ dims = {}
115
+ for j in range(ndims):
116
+ name, start, size, _coord, stored = struct.unpack_from("<4siifi", data, pos + 32 + 20 * j)
117
+ dims[name.rstrip(b"\x00").decode("cp1252")] = (start, size, stored or size)
118
+ if part:
119
+ raise UnsupportedFormatError("multi-part CZI (file parts)")
120
+ key = (pixel_type, compression, pyramid_type, tuple(sorted(dims.items())))
121
+ x0, w, sw = dims.pop("X")
122
+ y0, h, sh = dims.pop("Y")
123
+ entry = Subblock(
124
+ plane={d: v[0] for d, v in dims.items() if d not in "SM"},
125
+ x=x0, y=y0, width=w, height=h,
126
+ scene=dims.get("S", (0,))[0], mosaic=dims.get("M", (-1,))[0],
127
+ order=0, position=position, compression=compression, pixel_type=pixel_type,
128
+ level0=pyramid_type == 0 and (sw, sh) == (w, h), key=key,
129
+ stored_width=sw, stored_height=sh)
130
+ return entry, 32 + 20 * ndims
131
+
132
+
133
+ def _directory(fh) -> list:
134
+ """All subblock directory entries -> Subblocks (level 0 and pyramids)."""
135
+ fh.seek(0)
136
+ head = fh.read(32 + 80)
137
+ if head[:10] != MAGIC:
138
+ raise CorruptFileError("not a CZI file")
139
+ (major, minor, _, _, _, _, file_part, directory_position, _, _, _) = \
140
+ struct.unpack_from("<iiii16s16siqqiq", head, 32)
141
+ if not directory_position:
142
+ raise CorruptFileError("CZI without a subblock directory")
143
+ fh.seek(directory_position)
144
+ seg = fh.read(32)
145
+ if not seg[:16].rstrip(b"\x00") == b"ZISRAWDIRECTORY":
146
+ raise CorruptFileError(f"no ZISRAWDIRECTORY at byte {directory_position}")
147
+ allocated, used = struct.unpack_from("<qq", seg, 16)
148
+ data = fh.read(used or allocated)
149
+ count, = struct.unpack_from("<i", data)
150
+ pos, entries = 128, []
151
+ for n in range(count):
152
+ entry, size = _parse_entry(data, pos)
153
+ entry.order = n
154
+ entries.append(entry)
155
+ pos += size
156
+ return entries
157
+
158
+
159
+ SUBBLOCK_MAGIC = b"ZISRAWSUBBLOCK\x00\x00"
160
+
161
+
162
+ def _inline_key(fh, position: int):
163
+ """The key of the subblock segment at *position* (from the entry stored
164
+ inside it), or None if no subblock segment starts there."""
165
+ fh.seek(position)
166
+ head = fh.read(32 + 16 + 32 + 20 * 16)
167
+ if head[:16] != SUBBLOCK_MAGIC:
168
+ return None
169
+ try:
170
+ return _parse_entry(head, 32 + 16)[0].key
171
+ except (struct.error, UnicodeDecodeError, CorruptFileError, UnsupportedFormatError):
172
+ return None
173
+
174
+
175
+ def _ranges(values) -> str:
176
+ """[1, 2, 3, 7] -> '1-3, 7' (scene lists can be long)."""
177
+ out, values = [], sorted(values)
178
+ for _, group in itertools.groupby(enumerate(values), lambda p: p[1] - p[0]):
179
+ run = [v for _, v in group]
180
+ out.append(f"{run[0]}-{run[-1]}" if len(run) > 1 else str(run[0]))
181
+ return ", ".join(out)
182
+
183
+
184
+ class DamagedFileWarning(UserWarning):
185
+ """The file is damaged; micro-reader recovered what it could. Check or
186
+ re-copy the file: other readers will fail or return wrong pixels."""
187
+
188
+
189
+ def _recover(fh, path: str, entries: list) -> dict:
190
+ """Check that every entry points at its subblock; find the real position
191
+ of those that do not.
192
+
193
+ A subblock is accepted only where a subblock segment starts *and* the
194
+ entry stored inside it equals the directory's entry (scene, tile, plane,
195
+ rectangle, pixel type, compression). Fast path: the damage is usually one
196
+ insertion or removal, so the offset between the first misplaced entries
197
+ and their real subblocks applies to all later ones; whatever that leaves
198
+ is matched by scanning the whole file for subblock headers.
199
+ Returns a damage report ({} when the file is fine).
200
+ """
201
+ bad = [e for e in sorted(entries, key=lambda e: e.position)
202
+ if _inline_key(fh, e.position) != e.key]
203
+ if not bad:
204
+ return {}
205
+ first = bad[0].position
206
+ unresolved = {e.key: e for e in bad}
207
+ if len(unresolved) != len(bad):
208
+ raise CorruptFileError(f"{path!r}: damaged, and its directory lists the same "
209
+ "subblock twice; cannot recover")
210
+ shifts: list = []
211
+
212
+ def settle(shift):
213
+ for key, e in list(unresolved.items()):
214
+ if _inline_key(fh, e.position + shift) == key:
215
+ e.position += shift
216
+ del unresolved[key]
217
+
218
+ def scan(start, stop, on_hit):
219
+ chunk = 64 << 20
220
+ pos = start
221
+ while pos < stop:
222
+ fh.seek(pos)
223
+ buf = fh.read(min(chunk, stop - pos) + len(SUBBLOCK_MAGIC))
224
+ i = buf.find(SUBBLOCK_MAGIC)
225
+ while i >= 0:
226
+ if on_hit(pos + i):
227
+ return
228
+ i = buf.find(SUBBLOCK_MAGIC, i + 1)
229
+ pos += chunk
230
+
231
+ size = fh.seek(0, 2)
232
+
233
+ # fast path: shifts found near the first misplaced entry
234
+ def near(hit):
235
+ key = _inline_key(fh, hit)
236
+ if key in unresolved:
237
+ shift = hit - unresolved[key].position
238
+ shifts.append(shift)
239
+ settle(shift)
240
+ return not unresolved or len(shifts) >= 8
241
+ scan(max(0, first - (64 << 20)), min(size, first + (256 << 20)), near)
242
+
243
+ # fallback: every subblock header in the file
244
+ if unresolved:
245
+ found: dict = {}
246
+
247
+ def index(hit):
248
+ key = _inline_key(fh, hit)
249
+ if key in unresolved:
250
+ found.setdefault(key, []).append(hit)
251
+ return False
252
+ scan(0, size, index)
253
+ for key, hits in found.items():
254
+ if len(hits) == 1:
255
+ unresolved.pop(key).position = hits[0]
256
+ return {
257
+ "first_bad_offset": first,
258
+ "misplaced": len(bad),
259
+ "recovered": len(bad) - len(unresolved),
260
+ "unrecoverable": len(unresolved),
261
+ "shifts": sorted(set(shifts)),
262
+ "scenes_affected": sorted({e.scene for e in bad}),
263
+ "scenes_unreadable": sorted({e.scene for e in unresolved.values()}),
264
+ "unresolved_keys": set(unresolved),
265
+ }
266
+
267
+
268
+ def _subtract(a, b) -> list:
269
+ """Rectangle *a* minus rectangle *b*; rectangles are (y0, y1, x0, x1).
270
+ Returns up to four disjoint pieces."""
271
+ ay0, ay1, ax0, ax1 = a
272
+ by0, by1, bx0, bx1 = b
273
+ iy0, iy1, ix0, ix1 = max(ay0, by0), min(ay1, by1), max(ax0, bx0), min(ax1, bx1)
274
+ if iy0 >= iy1 or ix0 >= ix1:
275
+ return [a]
276
+ pieces = []
277
+ if ay0 < iy0:
278
+ pieces.append((ay0, iy0, ax0, ax1)) # above
279
+ if iy1 < ay1:
280
+ pieces.append((iy1, ay1, ax0, ax1)) # below
281
+ if ax0 < ix0:
282
+ pieces.append((iy0, iy1, ax0, ix0)) # left
283
+ if ix1 < ax1:
284
+ pieces.append((iy0, iy1, ix1, ax1)) # right
285
+ return pieces
286
+
287
+
288
+ class _PlaneTiles:
289
+ """The subblocks of one plane in drawing order, with a grid index so a
290
+ region finds its tiles without scanning them all (slide scans can have
291
+ tens of thousands per plane)."""
292
+
293
+ def __init__(self, subblocks: list, origin):
294
+ ox, oy = origin
295
+ self.subblocks = subblocks
296
+ self.rects = [(s.y - oy, s.y - oy + s.height, s.x - ox, s.x - ox + s.width)
297
+ for s in subblocks]
298
+ self.cell = (max(s.height for s in subblocks), max(s.width for s in subblocks))
299
+ self.grid: dict = {}
300
+ for i, (y0, y1, x0, x1) in enumerate(self.rects):
301
+ for cy in range(y0 // self.cell[0], (y1 - 1) // self.cell[0] + 1):
302
+ for cx in range(x0 // self.cell[1], (x1 - 1) // self.cell[1] + 1):
303
+ self.grid.setdefault((cy, cx), []).append(i)
304
+
305
+ def query(self, rect, after: int = -1) -> list:
306
+ """Indices (drawing order) of the tiles after *after* touching *rect*."""
307
+ y0, y1, x0, x1 = rect
308
+ found = set()
309
+ for cy in range(y0 // self.cell[0], (y1 - 1) // self.cell[0] + 1):
310
+ for cx in range(x0 // self.cell[1], (x1 - 1) // self.cell[1] + 1):
311
+ found.update(i for i in self.grid.get((cy, cx), ()) if i > after)
312
+ return sorted(i for i in found if _subtract(rect, self.rects[i]) != [rect])
313
+
314
+ def visible(self, i: int, region) -> list:
315
+ """The parts of tile *i* inside *region* that no later tile covers."""
316
+ y0, y1, x0, x1 = self.rects[i]
317
+ ry0, ry1, rx0, rx1 = region
318
+ clip = (max(y0, ry0), min(y1, ry1), max(x0, rx0), min(x1, rx1))
319
+ if clip[0] >= clip[1] or clip[2] >= clip[3]:
320
+ return []
321
+ pieces = [clip]
322
+ for j in self.query(clip, after=i):
323
+ pieces = [p for piece in pieces for p in _subtract(piece, self.rects[j])]
324
+ if not pieces:
325
+ break
326
+ return pieces
327
+
328
+
329
+ class _CziImage(Image):
330
+ """One image of a CZI (a mosaic tile, a stitched scene, or the canvas of
331
+ all scenes), composed from its subblocks."""
332
+
333
+ def __init__(self, czi: "_CziFile", index: int, subblocks: list, name: str,
334
+ key: dict, box: tuple = None):
335
+ if not subblocks:
336
+ raise CorruptFileError(f"{czi.path!r}: scene without subblocks")
337
+ types = {s.pixel_type for s in subblocks}
338
+ if len(types) != 1 or next(iter(types)) not in PIXEL_TYPES:
339
+ raise UnsupportedFormatError(f"{czi.path!r}: CZI pixel types {sorted(types)}")
340
+ unsupported = {s.compression for s in subblocks} - set(COMPRESSIONS)
341
+ if unsupported:
342
+ raise UnsupportedFormatError(
343
+ f"{czi.path!r}: CZI compression {sorted(unsupported)}: codes 100-999 "
344
+ "(camera RAW) and 1000+ (system RAW) are vendor-specific formats "
345
+ "without a public description; other codes are unknown")
346
+ image_key = key
347
+ dtype, self._samples = PIXEL_TYPES[next(iter(types))]
348
+ self._file_dtype = np.dtype(dtype)
349
+ self._czi = czi
350
+ # plane axes: T C Z always; B I H only where the file has several
351
+ axes = [d for d in AXIS_DIMS if d in "TCZ" or czi._dim_sizes.get(d, 1) > 1]
352
+ self._plane_dims = axes
353
+ if box is not None: # a resolution level: the scene's box, scaled
354
+ x0, y0, x1, y1 = box
355
+ else:
356
+ x0 = min(s.x for s in subblocks)
357
+ y0 = min(s.y for s in subblocks)
358
+ x1 = max(s.x + s.width for s in subblocks)
359
+ y1 = max(s.y + s.height for s in subblocks)
360
+ self.origin = (x0, y0) # scene box in image pixel coordinates
361
+ # plane -> subblocks in drawing order: scene, mosaic index, directory
362
+ # order (libCZI's order; the scene only matters on the whole-file canvas)
363
+ by_plane: dict = {}
364
+ for s in sorted(subblocks, key=lambda s: (s.scene, s.mosaic, s.order)):
365
+ key = tuple(s.plane.get(d, czi._dim_starts.get(d, 0)) - czi._dim_starts.get(d, 0)
366
+ for d in axes)
367
+ by_plane.setdefault(key, []).append(s)
368
+ self._planes = {key: _PlaneTiles(tiles, self.origin) for key, tiles in by_plane.items()}
369
+ shape = [czi._dim_sizes.get(d, 1) for d in axes] + [y1 - y0, x1 - x0]
370
+ letters = "".join(d.lower() for d in axes) + "yx"
371
+ if self._samples > 1:
372
+ shape.append(self._samples)
373
+ letters += "s"
374
+ self._closed = False
375
+ super().__init__(index, letters, shape, self._file_dtype, name=name, index=image_key)
376
+
377
+ # -- reading ---------------------------------------------------------------
378
+
379
+ def _data_offset(self, s: Subblock) -> int:
380
+ """File offset of a subblock's pixels (read from its segment header)."""
381
+ if not s.data_offset:
382
+ if s.position < 0:
383
+ raise CorruptFileError(
384
+ f"{self._czi.path!r} is damaged: the subblock for scene {s.scene}, "
385
+ f"tile {s.mosaic}, plane {s.plane} could not be found")
386
+ fh = self._czi._handles.get()
387
+ fh.seek(s.position)
388
+ head = fh.read(32 + 48)
389
+ if head[:16].rstrip(b"\x00") != b"ZISRAWSUBBLOCK":
390
+ raise CorruptFileError(f"{self._czi.path!r}: no subblock at byte {s.position}")
391
+ metadata_size, _attachment, data_size = struct.unpack_from("<iiq", head, 32)
392
+ ndims, = struct.unpack_from("<i", head, 32 + 44)
393
+ s.data_size = data_size
394
+ s.data_offset = s.position + 32 + 16 + max(240, 32 + 20 * ndims) + metadata_size
395
+ return s.data_offset
396
+
397
+ def _decoded(self, s: Subblock):
398
+ """A compressed subblock as (rows, cols x samples) in the file's
399
+ dtype, from the cache or decoded now; and the pixels decoded."""
400
+ tile = self._czi.decode_cache.get(s.position)
401
+ if tile is not None:
402
+ return tile, 0
403
+ nbytes = s.height * s.width * self._samples * self._file_dtype.itemsize
404
+ what = (f"{self._czi.path!r}: a {s.height} x {s.width} CZI subblock compressed with "
405
+ f"{COMPRESSIONS.get(s.compression, s.compression)}")
406
+ with _memory.reserve(nbytes, what):
407
+ tile = self._decode_subblock(s)
408
+ self._czi.decode_cache.put(s.position, tile)
409
+ return tile, tile.size
410
+
411
+ def _decode_subblock(self, s: Subblock) -> np.ndarray:
412
+ base = self._data_offset(s)
413
+ data = self._czi._handles.read(base, s.data_size)
414
+ tile = np.empty((s.height, s.width * self._samples), self._file_dtype)
415
+ if s.data_size == tile.nbytes and s.compression not in IMAGE_CODECS:
416
+ tile[...] = np.frombuffer(data, self._file_dtype).reshape(tile.shape) # stored raw
417
+ elif s.compression == ZSTD0:
418
+ _codecs.zstd_decompress_into(data, tile)
419
+ elif s.compression == ZSTD1: # header, then a zstd frame
420
+ header = data[0]
421
+ hilo = header >= 3 and data[1] == 1 and data[2] & 1
422
+ self._maybe_hilo(tile, hilo, lambda out: _codecs.zstd_decompress_into(
423
+ memoryview(data)[header:], out))
424
+ elif s.compression == LZW:
425
+ _codecs.lzw_decode_into(data, tile) # stored order, like raw data
426
+ elif s.compression == CHUNKED:
427
+ self._chunked(data, tile)
428
+ else: # image codecs: R, G, B out
429
+ self._image_codec(s, data, tile)
430
+ return tile
431
+
432
+ def _maybe_hilo(self, tile, hilo: bool, decode) -> None:
433
+ """Decode into *tile*, undoing hi-lo byte packing if it was applied."""
434
+ if hilo and self._file_dtype.itemsize == 2:
435
+ packed = np.empty(tile.nbytes, np.uint8)
436
+ decode(packed)
437
+ _codecs.hilo_unpack(packed, tile)
438
+ else:
439
+ decode(tile)
440
+
441
+ def _chunked(self, data: bytes, tile) -> None:
442
+ """Compression 7 (experimental in libCZI): varint header chunks
443
+ {1: compressed sizes, 2: method 0 zstd / 1 LZ4, 3: decompressed
444
+ sizes, 4: preprocessing 1 = hi-lo packing}, then the chunks back to
445
+ back (Zeiss, "Chunked compression")."""
446
+ def varint(buf, pos):
447
+ value = shift = 0
448
+ while True:
449
+ byte = buf[pos]
450
+ value |= (byte & 0x7F) << shift
451
+ pos += 1
452
+ if not byte & 0x80:
453
+ return value, pos
454
+ shift += 7
455
+
456
+ pos, fields = 0, {}
457
+ while True:
458
+ kind, pos = varint(data, pos)
459
+ if kind == 0:
460
+ break
461
+ length, pos = varint(data, pos)
462
+ fields[kind] = bytes(data[pos:pos + length])
463
+ pos += length
464
+ if 1 not in fields or 3 not in fields:
465
+ raise CorruptFileError(f"{self._czi.path!r}: chunked subblock without chunk sizes")
466
+ compressed, p = [], 0
467
+ while p < len(fields[1]):
468
+ n, p = varint(fields[1], p)
469
+ compressed.append(n)
470
+ sizes, p = [], 0
471
+ while p < len(fields[3]):
472
+ n, p = varint(fields[3], p)
473
+ sizes.append(n)
474
+ # [C]: every chunk C bytes; [C, L]: all C except the last, which is L
475
+ decompressed = [sizes[0]] * len(compressed)
476
+ if len(sizes) == 2:
477
+ decompressed[-1] = sizes[1]
478
+ elif len(sizes) not in (1, len(compressed)):
479
+ raise CorruptFileError(f"{self._czi.path!r}: chunked sizes {sizes}")
480
+ elif len(sizes) == len(compressed):
481
+ decompressed = sizes
482
+ method = fields.get(2, b"\x00")[0]
483
+ if method not in (0, 1):
484
+ raise UnsupportedFormatError(f"{self._czi.path!r}: chunked method {method}")
485
+ hilo = fields.get(4, b"\x00")[0] == 1
486
+ if sum(decompressed) != tile.nbytes:
487
+ raise CorruptFileError(f"{self._czi.path!r}: chunked data decodes to "
488
+ f"{sum(decompressed)} bytes, expected {tile.nbytes}")
489
+
490
+ def decode(out):
491
+ flat = out.reshape(-1).view(np.uint8)
492
+ src = dst = 0
493
+ view = memoryview(data)[pos:]
494
+ for csize, dsize in zip(compressed, decompressed):
495
+ chunk, target = view[src:src + csize], flat[dst:dst + dsize]
496
+ if method == 0:
497
+ _codecs.zstd_decompress_into(chunk, target)
498
+ else:
499
+ _codecs.lz4_block_decompress_into(chunk, target)
500
+ src, dst = src + csize, dst + dsize
501
+ self._maybe_hilo(tile, hilo, decode)
502
+
503
+ def _image_codec(self, s: Subblock, data: bytes, tile) -> None:
504
+ """JPEG, JPEG lossless, JPEG XR, JPEG XL: they give R, G, B; stored
505
+ into *tile* in the file's B, G, R order like every other codec."""
506
+ decoded = np.empty((s.height, s.width, self._samples), self._file_dtype)
507
+ if s.compression in (JPEG, JPEG_LOSSLESS):
508
+ width, height, samples, bits = _codecs.jpeg_decode_into(data, decoded)
509
+ elif s.compression == JPEG_XL:
510
+ width, height, samples, bits = _codecs.jxl_decode_into(data, decoded)
511
+ else: # JPEG_XR
512
+ _codecs.jpegxr_decode_into(data, decoded)
513
+ width, height, samples = s.width, s.height, self._samples
514
+ if (width, height, samples) != (s.width, s.height, self._samples):
515
+ raise CorruptFileError(
516
+ f"{self._czi.path!r}: subblock codestream is {width} x {height} x {samples}, "
517
+ f"the directory says {s.width} x {s.height} x {self._samples}")
518
+ if self._samples > 1:
519
+ decoded = decoded[..., [2, 1, 0, 3][:self._samples]] # R G B -> stored B G R
520
+ tile[...] = decoded.reshape(tile.shape)
521
+
522
+ def _draw(self, s: Subblock, piece, band_y0, xs, dst) -> int:
523
+ """Draw *piece* (y0, y1, x0, x1 in scene coordinates, inside subblock
524
+ *s* and inside the band) onto *dst* (the band: rows, cols[, samples])."""
525
+ ox, oy = self.origin
526
+ sy0, sx0 = s.y - oy, s.x - ox # tile's place in the scene
527
+ y0, y1, x0, x1 = piece
528
+ item = self._file_dtype.itemsize * self._samples
529
+ target = dst[y0 - band_y0:y1 - band_y0, x0 - xs.start:x1 - xs.start]
530
+ width = x1 - x0
531
+ if s.compression != UNCOMPRESSED:
532
+ nbytes = s.height * s.width * self._samples * self._file_dtype.itemsize
533
+ stream = self._stream_source(s) if _memory.should_stream(nbytes) else None
534
+ if stream is not None: # too large to cache: its rows
535
+ return self._draw_streamed(s, stream, piece, band_y0, xs, dst)
536
+ if _memory.should_stream(nbytes) and s.compression in (JPEG, JPEG_XL):
537
+ drawn = self._draw_area(s, piece, band_y0, xs, dst)
538
+ if drawn is not None: # the piece alone
539
+ return drawn
540
+ tile, read = self._decoded(s) # decoded whole (cached)
541
+ buf = tile[y0 - sy0:y1 - sy0, (x0 - sx0) * self._samples:(x1 - sx0) * self._samples]
542
+ self._place(buf, target, y1 - y0, width)
543
+ return read
544
+ base = self._data_offset(s)
545
+ fh = self._czi._handles.get()
546
+ if width == s.width: # whole tile rows: one read
547
+ buf = np.empty((y1 - y0, width * self._samples), self._file_dtype)
548
+ fh.seek(base + (y0 - sy0) * s.width * item)
549
+ if fh.readinto(memoryview(buf).cast("B")) != buf.nbytes:
550
+ raise CorruptFileError(f"{self._czi.path!r}: subblock data ends early")
551
+ else:
552
+ buf = np.empty((y1 - y0, width * self._samples), self._file_dtype)
553
+ for i in range(y1 - y0):
554
+ fh.seek(base + ((y0 - sy0 + i) * s.width + (x0 - sx0)) * item)
555
+ if fh.readinto(memoryview(buf[i]).cast("B")) != buf.shape[1] * buf.itemsize:
556
+ raise CorruptFileError(f"{self._czi.path!r}: subblock data ends early")
557
+ self._place(buf, target, y1 - y0, width)
558
+ return buf.size
559
+
560
+ def _stream_source(self, s: Subblock):
561
+ """(codec, offset, length) of subblock *s*'s compressed rows when they
562
+ can be streamed (``_stream``): zstd (``ZSTD0``; ``ZSTD1`` after its
563
+ header, without hi-lo packing, which splits the whole tile into its
564
+ low and high bytes), LZW, or data stored raw; else None."""
565
+ source = self._stream_codec(s)
566
+ return source if source is not None and _stream.streamable(source[0]) else None
567
+
568
+ def _stream_codec(self, s: Subblock):
569
+ base = self._data_offset(s)
570
+ nbytes = s.height * s.width * self._samples * self._file_dtype.itemsize
571
+ if s.data_size == nbytes and s.compression not in IMAGE_CODECS:
572
+ return "none", base, s.data_size # stored raw
573
+ if s.compression == ZSTD0:
574
+ return "zstd", base, s.data_size
575
+ if s.compression == LZW:
576
+ return "lzw", base, s.data_size
577
+ if s.compression == ZSTD1:
578
+ head = self._czi._handles.read(base, 3)
579
+ header = head[0]
580
+ hilo = header >= 3 and head[1] == 1 and head[2] & 1
581
+ if hilo and self._file_dtype.itemsize == 2:
582
+ return None
583
+ return "zstd", base + header, s.data_size - header
584
+ return None
585
+
586
+ def _draw_streamed(self, s: Subblock, source, piece, band_y0, xs, dst) -> int:
587
+ """*piece* of subblock *s* from its rows alone, decoded as a stream in
588
+ pieces of rows, each held within the memory budget."""
589
+ codec, offset, length = source
590
+ ox, oy = self.origin
591
+ sy0, sx0 = s.y - oy, s.x - ox
592
+ y0, y1, x0, x1 = piece
593
+ row = s.width * self._samples
594
+ row_bytes = row * self._file_dtype.itemsize
595
+ per_piece = max(1, min(STREAM_PIECE_BYTES, _memory.memory_limit() // 4) // row_bytes)
596
+ for a in range(y0, y1, per_piece):
597
+ b = min(y1, a + per_piece)
598
+ with _memory.reserve((b - a) * row_bytes, f"{self._czi.path!r}: rows of a subblock"):
599
+ buf = np.empty((b - a) * row_bytes, np.uint8)
600
+ n = _stream.read(self._czi.path, offset, length, codec, (a - sy0) * row_bytes, buf,
601
+ size=s.height * row_bytes)
602
+ if n < buf.nbytes:
603
+ raise CorruptFileError(f"{self._czi.path!r}: subblock data ends early")
604
+ rows = buf.view(self._file_dtype).reshape(b - a, row)
605
+ self._place(rows[:, (x0 - sx0) * self._samples:(x1 - sx0) * self._samples],
606
+ dst[a - band_y0:b - band_y0, x0 - xs.start:x1 - xs.start],
607
+ b - a, x1 - x0)
608
+ return (y1 - y0) * (x1 - x0) * self._samples
609
+
610
+ def _draw_area(self, s: Subblock, piece, band_y0, xs, dst):
611
+ """*piece* of a JPEG XL subblock (jxl-oxide renders only the groups
612
+ covering it) or of a JPEG subblock with restart markers (cut out of
613
+ it, ``_jpeg_restarts``), in bands of rows within the budget; None
614
+ for a JPEG that cannot be cut."""
615
+ ox, oy = self.origin
616
+ sy0, sx0 = s.y - oy, s.x - ox
617
+ y0, y1, x0, x1 = piece
618
+ base, size = self._data_offset(s), s.data_size
619
+ what = f"{self._czi.path!r}: rows of a subblock"
620
+ if s.compression == JPEG:
621
+ index = self._restart_index(s, base, size)
622
+ row_bytes = s.width * self._samples * self._file_dtype.itemsize
623
+ if not _jpeg_restarts.usable(index, row_bytes, _memory.fits):
624
+ return None
625
+ for a, b, rows in _jpeg_restarts.read_rows(
626
+ index, lambda p, q: self._czi._handles.read(base + p, q - p),
627
+ y0 - sy0, y1 - sy0, _codecs.jpeg_decode_into, (self._samples,),
628
+ self._file_dtype, min(STREAM_PIECE_BYTES, _memory.memory_limit() // 4),
629
+ lambda n: _memory.reserve(n, what)):
630
+ self._place_decoded(rows[:, x0 - sx0:x1 - sx0], a + sy0, b + sy0, piece,
631
+ band_y0, xs, dst)
632
+ return (y1 - y0) * (x1 - x0) * self._samples
633
+ data = self._czi._handles.read(base, size)
634
+ row_bytes = (x1 - x0) * self._samples * self._file_dtype.itemsize
635
+ per_band = max(1, min(STREAM_PIECE_BYTES, _memory.memory_limit() // 4) // row_bytes)
636
+ for a in range(y0, y1, per_band):
637
+ b = min(y1, a + per_band)
638
+ area = np.empty((b - a, x1 - x0, self._samples), self._file_dtype)
639
+ with _memory.reserve(area.nbytes, what):
640
+ got = _codecs.jxl_decode_area_into(data, area, x0 - sx0, a - sy0,
641
+ x1 - sx0, b - sy0)
642
+ if (got[1], got[0], got[2]) != area.shape:
643
+ raise CorruptFileError(
644
+ f"{self._czi.path!r}: JPEG XL area decodes to {got[1]} x {got[0]} x "
645
+ f"{got[2]}, expected {area.shape}")
646
+ self._place_decoded(area, a, b, piece, band_y0, xs, dst)
647
+ return (y1 - y0) * (x1 - x0) * self._samples
648
+
649
+ def _place_decoded(self, rows, a: int, b: int, piece, band_y0, xs, dst) -> None:
650
+ """Decoded rows *a*..*b* (scene coordinates) of *piece*, (rows, cols,
651
+ samples) in R, G, B order, onto *dst* in the file's B, G, R order."""
652
+ _, _, x0, x1 = piece
653
+ if self._samples > 1:
654
+ rows = rows[..., [2, 1, 0, 3][:self._samples]] # R G B -> stored B G R
655
+ self._place(np.ascontiguousarray(rows).reshape(b - a, (x1 - x0) * self._samples),
656
+ dst[a - band_y0:b - band_y0, x0 - xs.start:x1 - xs.start], b - a, x1 - x0)
657
+
658
+ def _restart_index(self, s: Subblock, base: int, size: int):
659
+ """The ``RestartIndex`` of JPEG subblock *s* (None: it cannot be
660
+ cut), built once from its compressed bytes."""
661
+ restarts = self._czi.restarts
662
+ if s.position not in restarts:
663
+ with _memory.reserve(size, f"{self._czi.path!r}: a compressed JPEG subblock"):
664
+ index = _jpeg_restarts.RestartIndex.build(self._czi._handles.read(base, size))
665
+ restarts[s.position] = index
666
+ return restarts[s.position]
667
+
668
+ def _place(self, buf, target, rows: int, width: int) -> None:
669
+ """Pixels (rows, width x samples) as stored -> *target*; samples
670
+ from the stored B, G, R (A) to R, G, B (A)."""
671
+ if self._samples > 1:
672
+ pixels = buf.reshape(rows, width, self._samples)
673
+ target[...] = pixels[..., [2, 1, 0, 3][:self._samples]]
674
+ else:
675
+ target[...] = buf
676
+
677
+ def _draw_span(self, s: Subblock, pieces, band_y0, xs, dst) -> int:
678
+ """Uncompressed *pieces* of subblock *s* from ONE contiguous read: from
679
+ the first piece pixel to the last, the bytes between them included."""
680
+ ox, oy = self.origin
681
+ sy0, sx0 = s.y - oy, s.x - ox
682
+ by0, by1 = min(p[0] for p in pieces), max(p[1] for p in pieces)
683
+ bx0, bx1 = min(p[2] for p in pieces), max(p[3] for p in pieces)
684
+ row = s.width * self._samples # elements per tile row
685
+ count = (by1 - by0 - 1) * row + (bx1 - bx0) * self._samples
686
+ buf = np.empty((by1 - by0) * row, self._file_dtype)
687
+ fh = self._czi._handles.get()
688
+ fh.seek(self._data_offset(s)
689
+ + ((by0 - sy0) * row + (bx0 - sx0) * self._samples) * self._file_dtype.itemsize)
690
+ if fh.readinto(memoryview(buf[:count]).cast("B")) != count * buf.itemsize:
691
+ raise CorruptFileError(f"{self._czi.path!r}: subblock data ends early")
692
+ rows = buf.reshape(by1 - by0, row) # column 0 = pixel bx0
693
+ for y0, y1, x0, x1 in pieces:
694
+ self._place(rows[y0 - by0:y1 - by0,
695
+ (x0 - bx0) * self._samples:(x1 - bx0) * self._samples],
696
+ dst[y0 - band_y0:y1 - band_y0, x0 - xs.start:x1 - xs.start],
697
+ y1 - y0, x1 - x0)
698
+ return count
699
+
700
+ def _draw_band(self, tiles: _PlaneTiles, y0, y1, xs, dst) -> int:
701
+ """One plane onto one row band: only the part of each tile no later
702
+ tile covers is drawn, and a tile hidden entirely is never read or
703
+ decoded (bands are disjoint, so they can be drawn in parallel).
704
+ An uncompressed tile's visible pieces come from one contiguous read
705
+ unless the bytes skipped between them cost more than the separate
706
+ reads they save (every piece narrower than the tile is one read per
707
+ row)."""
708
+ region, read = (y0, y1, xs.start, xs.stop), 0
709
+ item = self._file_dtype.itemsize * self._samples
710
+ for i in tiles.query(region):
711
+ s, pieces = tiles.subblocks[i], tiles.visible(i, region)
712
+ if not pieces:
713
+ continue
714
+ if s.compression == UNCOMPRESSED:
715
+ by0, by1 = min(p[0] for p in pieces), max(p[1] for p in pieces)
716
+ bx0, bx1 = min(p[2] for p in pieces), max(p[3] for p in pieces)
717
+ span = (by1 - by0 - 1) * s.width + (bx1 - bx0)
718
+ skipped = (span - sum((p[1] - p[0]) * (p[3] - p[2]) for p in pieces)) * item
719
+ reads = sum(1 if p[3] - p[2] == s.width else p[1] - p[0] for p in pieces)
720
+ if len(pieces) > 1 or reads > 1:
721
+ if skipped <= (reads - 1) * SEPARATE_READ_BYTES:
722
+ read += self._draw_span(s, pieces, y0, xs, dst)
723
+ continue
724
+ for piece in pieces:
725
+ read += self._draw(s, piece, y0, xs, dst)
726
+ return read
727
+
728
+ def _read_block(self, block):
729
+ if self._closed:
730
+ raise ValueError("this CZI image has been closed: its file was closed "
731
+ "(File.close() or the end of a with block)")
732
+ n = len(self._plane_dims)
733
+ plane_spans, ys, xs = block[:n], block[n], block[n + 1]
734
+ ss = block[n + 2] if self._samples > 1 else None
735
+ out = np.zeros(tuple(s.stop - s.start for s in block[:n + 2])
736
+ + ((self._samples,) if ss is not None else ()), dtype=self.dtype)
737
+ row_bytes = max(1, (xs.stop - xs.start) * self._samples * self._file_dtype.itemsize)
738
+ per_task = max(1, RAW_TASK_BYTES // row_bytes)
739
+ tasks = []
740
+ for key in itertools.product(*(range(s.start, s.stop) for s in plane_spans)):
741
+ tiles = self._planes.get(key)
742
+ if tiles is None:
743
+ continue # nothing acquired: background
744
+ dst = out[tuple(k - s.start for k, s in zip(key, plane_spans))]
745
+ for b0 in range(ys.start, ys.stop, per_task):
746
+ b1 = min(ys.stop, b0 + per_task)
747
+ part = dst[b0 - ys.start:b1 - ys.start]
748
+ tasks.append(lambda a=(tiles, b0, b1, xs, part): self._draw_band(*a))
749
+ read = _pool.run(tasks)
750
+ if ss is not None and (ss.start, ss.stop) != (0, self._samples):
751
+ out = out[..., ss]
752
+ return out, read
753
+
754
+ def read_region(self, x: int, y: int, width: int, height: int, **planes) -> np.ndarray:
755
+ """A rectangle in the file's own pixel coordinates (libCZI's ``roi``,
756
+ e.g. from ``origin`` or the XML metadata), composed from this scene's
757
+ tiles; parts outside the scene's box are background (0), as libCZI
758
+ returns them. *planes* select the other axes like ``read``."""
759
+ if width < 0 or height < 0:
760
+ raise ValueError(f"negative region size {width} x {height}")
761
+ ox, oy = self.origin
762
+ ny, nx = self.shape[self.axes.index("y")], self.shape[self.axes.index("x")]
763
+ y0, y1 = min(max(y - oy, 0), ny), min(max(y + height - oy, 0), ny)
764
+ x0, x1 = min(max(x - ox, 0), nx), min(max(x + width - ox, 0), nx)
765
+ y1, x1 = max(y0, y1), max(x0, x1)
766
+ inside = self.read(**planes, y=slice(y0, y1), x=slice(x0, x1))
767
+ if (y1 - y0, x1 - x0) == (height, width):
768
+ return inside
769
+ # y, x are the last two axes, or before the samples axis if it is kept
770
+ at = -3 if self._samples > 1 and not isinstance(planes.get("s"), (int, np.integer)) else -2
771
+ shape = list(inside.shape)
772
+ shape[at], shape[at + 1] = height, width
773
+ out = np.zeros(shape, inside.dtype)
774
+ index = [slice(None)] * out.ndim
775
+ index[at] = slice(y0 + oy - y, y1 + oy - y)
776
+ index[at + 1] = slice(x0 + ox - x, x1 + ox - x)
777
+ out[tuple(index)] = inside
778
+ return out
779
+
780
+ def _read_metadata(self):
781
+ from ._czi_metadata import czi_metadata
782
+ return czi_metadata(self, self._czi._handles.get(), self._czi._xml_root)
783
+
784
+ def close(self) -> None:
785
+ self._closed = True
786
+
787
+
788
+ class _CziFile(File):
789
+ backend = NAME
790
+
791
+ def __init__(self, path: str, tiles: str = "separate", resolutions: str = "base"):
792
+ if tiles not in TILE_POLICIES:
793
+ raise ValueError(f"tiles={tiles!r}: one of {TILE_POLICIES}")
794
+ self.path = path
795
+ self._handles = ThreadHandles(path)
796
+ #: decoded compressed tiles, shared by all images of the file
797
+ self.decode_cache = DecodeCache(DECODE_CACHE_BYTES)
798
+ #: subblock position -> RestartIndex | None of large JPEG subblocks
799
+ self.restarts: dict = {}
800
+ with builtins.open(path, "rb") as fh:
801
+ entries = _directory(fh)
802
+ level0 = [e for e in entries if e.level0] or entries
803
+ #: pyramid subblocks (lower resolution levels of whole scenes)
804
+ pyramid = [e for e in entries if not e.level0] if level0 is not entries else []
805
+ #: {} for a sound file; otherwise what was damaged and recovered
806
+ self.damage = _recover(fh, path, level0)
807
+ if self.damage:
808
+ for e in level0:
809
+ if e.key in self.damage["unresolved_keys"]:
810
+ e.position = -1 # reading it raises
811
+ d = self.damage
812
+ lost = (f"; {d['unrecoverable']} subblocks could NOT be found (scenes "
813
+ f"{d['scenes_unreadable']}) and cannot be read"
814
+ if d["unrecoverable"] else "")
815
+ warnings.warn(DamagedFileWarning(
816
+ f"DAMAGED CZI FILE: {path!r}. Its subblock directory does not match the "
817
+ f"data from byte {d['first_bad_offset']:,} on ({d['misplaced']} of "
818
+ f"{len(level0)} subblocks misplaced, scenes {_ranges(d['scenes_affected'])}). "
819
+ f"micro-reader recovered the real position of {d['recovered']} of them "
820
+ f"(shift {d['shifts']} bytes), each verified against the subblock's own "
821
+ f"header{lost}. Other readers (pylibCZIrw, aicspylibczi, Bio-Formats) fail "
822
+ "or return wrong pixels on this file: check the file or copy it again."),
823
+ stacklevel=3)
824
+ self._level0 = level0
825
+ # dimension ranges over the whole file (as bioio / pylibCZIrw count them)
826
+ self._dim_starts, self._dim_sizes = {}, {}
827
+ for d in PLANE_DIMS:
828
+ values = [e.plane[d] for e in level0 if d in e.plane]
829
+ if values:
830
+ self._dim_starts[d] = min(values)
831
+ self._dim_sizes[d] = max(values) - min(values) + 1
832
+ per_scene: dict = {}
833
+ for e in level0:
834
+ per_scene.setdefault(e.scene, set()).add(e.mosaic)
835
+ #: some scene has several mosaic tiles
836
+ self._tiled = any(len(m) > 1 for m in per_scene.values())
837
+ #: indices this file has, besides "scene"
838
+ self._levels = (["tile"] if self._tiled and tiles == "separate" else []) + \
839
+ [level for level, d in KEY_DIMS.items() if self._dim_sizes.get(d, 1) > 1]
840
+ groups: dict = {}
841
+ for e in level0:
842
+ groups.setdefault(self._key_of(e), []).append(e)
843
+ base = os.path.basename(path)
844
+ # resolution levels: stored for whole scenes only (pyramid subblocks
845
+ # carry no tile index), and not trusted in a repaired directory
846
+ with_levels = "tile" not in self._levels and pyramid and not self.damage
847
+ listed = resolutions == "all"
848
+ images = []
849
+ for values in sorted(groups):
850
+ key = dict(zip(["scene"] + self._levels, values))
851
+ name = base + " " + " ".join(f"{k} {v}" for k, v in key.items())
852
+ if listed:
853
+ key["resolution"] = 0
854
+ image = _CziImage(self, len(images), groups[values], name, key)
855
+ images.append(image)
856
+ if with_levels:
857
+ levels = self._resolution_levels(image, pyramid, values, name, key)
858
+ if levels:
859
+ image._resolutions = [image] + levels
860
+ for level in levels:
861
+ level._resolutions = image._resolutions
862
+ if listed:
863
+ images.extend(levels)
864
+ for n, image in enumerate(images):
865
+ image._position = n
866
+ super().__init__(path, images)
867
+
868
+ def _resolution_levels(self, image: "_CziImage", pyramid: list, values: tuple, name: str,
869
+ key: dict) -> list:
870
+ """*image*'s lower resolution levels: one per stored zoom of its scene
871
+ (x rotation, view), each composed from that zoom's pyramid subblocks
872
+ at their scaled positions in a grid of the scene's size x zoom (rounded
873
+ down, as libCZI sizes a zoomed read)."""
874
+ mine = [e for e in pyramid if self._key_of(e) == values]
875
+ if not mine:
876
+ return []
877
+ x0, y0 = image.origin
878
+ ny, nx = image.shape[image.axes.index("y")], image.shape[image.axes.index("x")]
879
+ by_zoom: dict = {}
880
+ for e in mine:
881
+ by_zoom.setdefault(round(e.stored_width / e.width, 6), []).append(e)
882
+ levels = []
883
+ for k, zoom in enumerate(sorted(by_zoom, reverse=True), start=1):
884
+ # coordinates in the file's frame at this zoom: the scene's corner
885
+ # (its origin) scaled, plus each subblock's scaled offset from it
886
+ ox, oy = int(np.floor(x0 * zoom)), int(np.floor(y0 * zoom))
887
+ placed = [replace(e, x=ox + int(np.floor((e.x - x0) * zoom)),
888
+ y=oy + int(np.floor((e.y - y0) * zoom)),
889
+ width=e.stored_width, height=e.stored_height)
890
+ for e in by_zoom[zoom]]
891
+ level = _CziImage(self, 0, placed, f"{name} resolution {k}",
892
+ {**key, "resolution": k},
893
+ box=(ox, oy, ox + int(nx * zoom), oy + int(ny * zoom)))
894
+ level.zoom = zoom
895
+ levels.append(level)
896
+ return levels
897
+
898
+ @property
899
+ def _xml_root(self):
900
+ """The root element of the file's XML metadata (parsed on first
901
+ use), or None."""
902
+ if not hasattr(self, "_xml"):
903
+ from ._czi_metadata import read_xml
904
+ self._xml = read_xml(self._handles.get())
905
+ return self._xml
906
+
907
+ def _key_of(self, e: Subblock) -> tuple:
908
+ """The image index values of subblock *e*, in index order."""
909
+ values = [e.scene]
910
+ for level in self._levels:
911
+ if level == "tile":
912
+ values.append(max(e.mosaic, 0))
913
+ else:
914
+ d = KEY_DIMS[level]
915
+ values.append(e.plane.get(d, self._dim_starts[d]))
916
+ return tuple(values)
917
+
918
+ def canvas(self, **key) -> _CziImage:
919
+ """All scenes composed on one canvas: the box around every level-0
920
+ tile of the file (libCZI's ``total_bounding_rectangle_no_pyramid``),
921
+ background between the scenes. Its ``origin`` is the box's corner.
922
+ A file with several rotations / views needs ``rotation=`` /
923
+ ``view=``: they are different frames."""
924
+ unknown = set(key) - set(KEY_DIMS)
925
+ if unknown:
926
+ raise KeyError(f"canvas: unknown indices {sorted(unknown)}; "
927
+ f"it takes {sorted(KEY_DIMS)}")
928
+ missing = [level for level, d in KEY_DIMS.items()
929
+ if self._dim_sizes.get(d, 1) > 1 and level not in key]
930
+ if missing:
931
+ raise ValueError(f"{self.path!r} has several {' and '.join(missing)}s: "
932
+ f"canvas({missing[0]}=...) picks one")
933
+ subblocks = [e for e in self._level0
934
+ if all(e.plane.get(KEY_DIMS[k], self._dim_starts.get(KEY_DIMS[k], 0)) == v
935
+ for k, v in key.items())]
936
+ if not subblocks:
937
+ raise ValueError(f"canvas: no subblocks with {key}")
938
+ return _CziImage(self, len(self.images), subblocks,
939
+ f"{os.path.basename(self.path)} canvas", dict(key))
940
+
941
+ def close(self) -> None:
942
+ super().close()
943
+ self._handles.close()
944
+ _stream.forget(self.path)
945
+
946
+
947
+ def open(path: str, tiles: str = "separate", resolutions: str = "base") -> _CziFile: # noqa: A001 - backend protocol
948
+ return _CziFile(path, tiles, resolutions)