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