worldarray 0.1.0__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.
- worldarray/__init__.py +43 -0
- worldarray/array.py +935 -0
- worldarray/context.py +53 -0
- worldarray/geometry.py +173 -0
- worldarray/levels.py +147 -0
- worldarray/placement.py +739 -0
- worldarray/resample.py +229 -0
- worldarray/retry.py +76 -0
- worldarray/store.py +427 -0
- worldarray/units.py +77 -0
- worldarray-0.1.0.dist-info/METADATA +214 -0
- worldarray-0.1.0.dist-info/RECORD +14 -0
- worldarray-0.1.0.dist-info/WHEEL +4 -0
- worldarray-0.1.0.dist-info/licenses/LICENSE +28 -0
worldarray/array.py
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
"""``WorldArray``: a stored array, where it sits, and how to read it.
|
|
2
|
+
|
|
3
|
+
One class for both kinds of consumer. Index-space readers (chunkmirage, CineMap,
|
|
4
|
+
liconn-err) use ``.ts``, ``.meta`` and ``read_index`` and keep native units
|
|
5
|
+
(``units=None``). World-box readers (cellmap-flow, cellmap-analyze, mesh-n-bone) use
|
|
6
|
+
``read`` with boxes in the units they ask for, nanometres by default (spec, section 2).
|
|
7
|
+
|
|
8
|
+
Subclass or compose, as you like: every piece of internal state is underscore-prefixed, and
|
|
9
|
+
the public attributes ``path``, ``units``, ``context``, ``padding``, ``method``,
|
|
10
|
+
``retries`` and ``transform`` are the defaults the reads use, settable at any time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import warnings
|
|
18
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import tensorstore as ts
|
|
24
|
+
|
|
25
|
+
from . import resample as _resample
|
|
26
|
+
from . import units as U
|
|
27
|
+
from .context import shared_context
|
|
28
|
+
from .geometry import Box, index_range, is_aligned
|
|
29
|
+
from .levels import LevelRef, Mode, discover, same_voxel_size, select_level
|
|
30
|
+
from .placement import ArrayMeta, light_levels, read_meta, read_meta_light
|
|
31
|
+
from .retry import Retry, read_with_retries
|
|
32
|
+
from .store import Node, detect, is_remote, open_array, precomputed_scale_index
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
Units = str | dict[str, str] | None
|
|
37
|
+
Transform = Callable[[np.ndarray], np.ndarray]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unit_targets(units: Units) -> tuple[str | None, str | None]:
|
|
41
|
+
if units is None:
|
|
42
|
+
return None, None
|
|
43
|
+
if isinstance(units, str):
|
|
44
|
+
return units, None
|
|
45
|
+
return units.get("space"), units.get("time")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WorldArray:
|
|
49
|
+
"""A stored array in world coordinates.
|
|
50
|
+
|
|
51
|
+
``path`` names an array (then it is the array that is read, whatever ``voxel_size``
|
|
52
|
+
asks for), a level under a multiscale group, or a group (then ``level`` or
|
|
53
|
+
``voxel_size`` picks the level; a group with neither raises, listing its levels).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
level: str | int | None = None,
|
|
61
|
+
voxel_size: Sequence[float] | None = None,
|
|
62
|
+
level_mode: Mode = "floor",
|
|
63
|
+
on_mismatch: str = "error",
|
|
64
|
+
padding: Any = 0,
|
|
65
|
+
method: str = "nearest",
|
|
66
|
+
select: dict[str, int] | None = None,
|
|
67
|
+
units: Units = "nanometer",
|
|
68
|
+
context: ts.Context | None = None,
|
|
69
|
+
retries: Retry | None = None,
|
|
70
|
+
metadata_conflicts: str = "error",
|
|
71
|
+
base_voxel_size: Sequence[float] | None = None,
|
|
72
|
+
base_units: str | None = None,
|
|
73
|
+
anchor: Sequence[float] | None = None,
|
|
74
|
+
transform: Transform | None = None,
|
|
75
|
+
strict_units: bool = True,
|
|
76
|
+
_ref: LevelRef | None = None,
|
|
77
|
+
_node: Node | None = None,
|
|
78
|
+
):
|
|
79
|
+
if on_mismatch not in ("error", "resample", "relabel", "keep"):
|
|
80
|
+
raise ValueError("on_mismatch is 'error', 'resample', 'relabel' or 'keep'")
|
|
81
|
+
self.path = path
|
|
82
|
+
self.padding = padding
|
|
83
|
+
self.method = method
|
|
84
|
+
self.units = units
|
|
85
|
+
self.transform = transform
|
|
86
|
+
self.context = context if context is not None else shared_context(0)
|
|
87
|
+
self.retries = retries if retries is not None else Retry()
|
|
88
|
+
self._select = dict(select or {})
|
|
89
|
+
self._opts = dict(
|
|
90
|
+
level=level,
|
|
91
|
+
voxel_size=voxel_size,
|
|
92
|
+
level_mode=level_mode,
|
|
93
|
+
on_mismatch=on_mismatch,
|
|
94
|
+
metadata_conflicts=metadata_conflicts,
|
|
95
|
+
base_voxel_size=base_voxel_size,
|
|
96
|
+
base_units=base_units,
|
|
97
|
+
anchor=anchor,
|
|
98
|
+
strict_units=strict_units,
|
|
99
|
+
)
|
|
100
|
+
self._anchor = tuple(float(a) for a in anchor) if anchor is not None else None
|
|
101
|
+
|
|
102
|
+
node = _node if _node is not None else detect(path)
|
|
103
|
+
self._pyramid: Pyramid | None = None
|
|
104
|
+
ref = _ref
|
|
105
|
+
if ref is None:
|
|
106
|
+
ref = self._choose_level(node, level, voxel_size, level_mode, on_mismatch)
|
|
107
|
+
self._ref = ref
|
|
108
|
+
node_for_level = (
|
|
109
|
+
node if (ref.scale_index is not None or ref.path == node.path) else detect(ref.path)
|
|
110
|
+
)
|
|
111
|
+
self._node = node_for_level
|
|
112
|
+
self._store = open_array(node_for_level, context=self.context, scale_index=ref.scale_index)
|
|
113
|
+
self._meta = read_meta(
|
|
114
|
+
node_for_level, self._store, scale_index=ref.scale_index, conflicts=metadata_conflicts
|
|
115
|
+
)
|
|
116
|
+
self._apply_select()
|
|
117
|
+
if base_voxel_size is not None:
|
|
118
|
+
self._meta = self._rescaled_to_base(base_voxel_size, base_units)
|
|
119
|
+
space, time = _unit_targets(units)
|
|
120
|
+
self._grid = self._meta.in_units(space=space, time=time, strict=strict_units)
|
|
121
|
+
|
|
122
|
+
self._target: tuple[float, ...] | None = None
|
|
123
|
+
if voxel_size is not None:
|
|
124
|
+
requested = tuple(float(v) for v in voxel_size)
|
|
125
|
+
if not same_voxel_size(self._source_voxel_size(), requested):
|
|
126
|
+
if on_mismatch == "error":
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"{path}: no level at voxel size {list(requested)}; levels: {self.levels}"
|
|
129
|
+
)
|
|
130
|
+
if on_mismatch == "resample":
|
|
131
|
+
self._target = requested
|
|
132
|
+
elif on_mismatch == "keep":
|
|
133
|
+
pass # the chosen level, at its own voxel size
|
|
134
|
+
else:
|
|
135
|
+
# relabel: read the level voxel for voxel and call its voxels the requested
|
|
136
|
+
# size; voxel i stays voxel i, so every world quantity scales by the ratio
|
|
137
|
+
# (the corner moves with it: cellmap-flow's rule, which chunkmirage_server uses)
|
|
138
|
+
vs, tr = list(self._grid.voxel_size), list(self._grid.translation)
|
|
139
|
+
for k, axis in enumerate(self._grid.spatial):
|
|
140
|
+
ratio = requested[k] / vs[axis]
|
|
141
|
+
vs[axis] = requested[k]
|
|
142
|
+
tr[axis] *= ratio
|
|
143
|
+
self._grid = replace(self._grid, voxel_size=tuple(vs), translation=tuple(tr))
|
|
144
|
+
|
|
145
|
+
# --- construction helpers -------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _choose_level(self, node: Node, level, voxel_size, level_mode, on_mismatch) -> LevelRef:
|
|
148
|
+
if level == "first":
|
|
149
|
+
level = 0
|
|
150
|
+
if node.format == "precomputed" and node.scale_key is not None and level is None:
|
|
151
|
+
index = precomputed_scale_index(node.docs["info"], node.scale_key)
|
|
152
|
+
return LevelRef(node.scale_key, node.path, index)
|
|
153
|
+
if node.kind == "array":
|
|
154
|
+
if level is not None and level != 0: # "first" (already 0) and 0 mean "this array"
|
|
155
|
+
warnings.warn(f"{self.path} is an array; level={level!r} is ignored", stacklevel=3)
|
|
156
|
+
return LevelRef(node.path.rsplit("/", 1)[-1], node.path) # the array itself, always
|
|
157
|
+
self._pyramid = Pyramid(
|
|
158
|
+
self.path, context=self.context, units=self.units, select=self._select, _node=node
|
|
159
|
+
)
|
|
160
|
+
if level is not None:
|
|
161
|
+
return self._pyramid._ref(level)
|
|
162
|
+
if voxel_size is not None:
|
|
163
|
+
mode = "resample" if on_mismatch == "resample" else level_mode
|
|
164
|
+
return self._pyramid._select_ref(voxel_size, mode)
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"{self.path} is a group; pass level=<name or index> or voxel_size=...; levels: {self._pyramid.names}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _apply_select(self) -> None:
|
|
170
|
+
for name, index in sorted(
|
|
171
|
+
self._select.items(), key=lambda kv: -self._meta.axes.index(kv[0])
|
|
172
|
+
):
|
|
173
|
+
if name not in self._meta.axes:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"{self.path}: no axis named {name!r} to select; axes are {self._meta.axes}"
|
|
176
|
+
)
|
|
177
|
+
axis = self._meta.axes.index(name)
|
|
178
|
+
self._store = self._store[ts.d[axis][int(index)]]
|
|
179
|
+
self._meta = self._meta.drop_axis(axis)
|
|
180
|
+
|
|
181
|
+
def _rescaled_to_base(self, base: Sequence[float], base_units: str | None) -> ArrayMeta:
|
|
182
|
+
"""This level's metadata with the pyramid's level 0 declared to have voxels of
|
|
183
|
+
``base`` (in ``base_units``): every level's spatial voxel size and translation scale
|
|
184
|
+
by the same per-axis factor, so stores whose metadata is in voxels of level 0 (as
|
|
185
|
+
BigStitcher writes) or unitless (a BigDataViewer N5) can be placed correctly."""
|
|
186
|
+
base = tuple(float(v) for v in base)
|
|
187
|
+
native = Pyramid(self.path, context=self.context, units=None, select=self._select)
|
|
188
|
+
level0 = native.levels[0].meta if len(native) > 1 else self._meta
|
|
189
|
+
spatial = self._meta.spatial
|
|
190
|
+
if len(base) != len(spatial):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"base_voxel_size has {len(base)} entries; the array has {len(spatial)} spatial axes"
|
|
193
|
+
)
|
|
194
|
+
vs, tr, units = (
|
|
195
|
+
list(self._meta.voxel_size),
|
|
196
|
+
list(self._meta.translation),
|
|
197
|
+
list(self._meta.units),
|
|
198
|
+
)
|
|
199
|
+
for k, axis in enumerate(spatial):
|
|
200
|
+
factor = base[k] / level0.voxel_size[axis]
|
|
201
|
+
vs[axis] *= factor
|
|
202
|
+
tr[axis] *= factor
|
|
203
|
+
if base_units is not None:
|
|
204
|
+
units[axis] = base_units
|
|
205
|
+
return replace(self._meta, voxel_size=tuple(vs), translation=tuple(tr), units=tuple(units))
|
|
206
|
+
|
|
207
|
+
def _source_voxel_size(self) -> tuple[float, ...]:
|
|
208
|
+
return tuple(self._grid.voxel_size[i] for i in self._grid.spatial)
|
|
209
|
+
|
|
210
|
+
def _origins(self) -> list[float]:
|
|
211
|
+
"""The target grid's corner, per spatial axis, in source voxels from the source corner."""
|
|
212
|
+
if self._target is None:
|
|
213
|
+
return [0.0] * len(self._grid.spatial)
|
|
214
|
+
corner = self._grid.corner
|
|
215
|
+
anchors = self.anchor
|
|
216
|
+
return [
|
|
217
|
+
(anchors[k] - corner[axis]) / self._grid.voxel_size[axis]
|
|
218
|
+
for k, axis in enumerate(self._grid.spatial)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# --- what it is -------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def ts(self) -> ts.TensorStore:
|
|
225
|
+
"""The raw tensorstore: C order, origin 0, selected axes dropped."""
|
|
226
|
+
return self._store
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def meta(self) -> ArrayMeta:
|
|
230
|
+
"""Metadata in the store's own units."""
|
|
231
|
+
return self._meta
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def grid(self) -> ArrayMeta:
|
|
235
|
+
"""The stored level's metadata in the requested units (``units``), all axes."""
|
|
236
|
+
return self._grid
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def shape(self) -> tuple[int, ...]:
|
|
240
|
+
"""The shape reads see: the stored shape, or with a resample target the number of
|
|
241
|
+
target voxels covering the data on each spatial axis."""
|
|
242
|
+
if self._target is None:
|
|
243
|
+
return self._grid.shape
|
|
244
|
+
shape = list(self._grid.shape)
|
|
245
|
+
for k, axis in enumerate(self._grid.spatial):
|
|
246
|
+
shape[axis] = _resample.target_shape(
|
|
247
|
+
self._grid.shape[axis],
|
|
248
|
+
self._grid.voxel_size[axis],
|
|
249
|
+
self._target[k],
|
|
250
|
+
self._origins()[k],
|
|
251
|
+
)
|
|
252
|
+
return tuple(shape)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def dtype(self) -> np.dtype:
|
|
256
|
+
return self._grid.dtype
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def axes(self) -> tuple[str, ...]:
|
|
260
|
+
return self._grid.axes
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def spatial(self) -> tuple[int, ...]:
|
|
264
|
+
return self._grid.spatial
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def voxel_size(self) -> tuple[float, ...]:
|
|
268
|
+
"""Spatial axes, in ``units``; the resample target when one is set."""
|
|
269
|
+
if self._target is not None:
|
|
270
|
+
return self._target
|
|
271
|
+
return self._source_voxel_size()
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def source_voxel_size(self) -> tuple[float, ...]:
|
|
275
|
+
"""The stored level's voxel size on the spatial axes, in ``units``."""
|
|
276
|
+
return self._source_voxel_size()
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def resampled(self) -> bool:
|
|
280
|
+
"""Whether reads resample to a target voxel size (``on_mismatch="resample"``)."""
|
|
281
|
+
return self._target is not None
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def translation(self) -> tuple[float, ...]:
|
|
285
|
+
"""The centre of voxel 0 on the spatial axes, in ``units``."""
|
|
286
|
+
if self._target is not None:
|
|
287
|
+
return tuple(c + t / 2 for c, t in zip(self.corner, self._target))
|
|
288
|
+
return tuple(self._grid.translation[i] for i in self._grid.spatial)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def anchor(self) -> tuple[float, ...]:
|
|
292
|
+
"""Where a target voxel corner sits when resampling: the source corner unless
|
|
293
|
+
``anchor`` was given, so a volume can be read onto another volume's grid."""
|
|
294
|
+
if self._anchor is not None:
|
|
295
|
+
return self._anchor
|
|
296
|
+
c = self._grid.corner
|
|
297
|
+
return tuple(c[i] for i in self._grid.spatial)
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def corner(self) -> tuple[float, ...]:
|
|
301
|
+
"""``translation - voxel_size / 2`` on the spatial axes: what funlib, daisy and
|
|
302
|
+
Neuroglancer call the offset. The same corner at every voxel size, unless an
|
|
303
|
+
``anchor`` moved the target grid."""
|
|
304
|
+
if self._target is not None and self._anchor is not None:
|
|
305
|
+
return self._anchor
|
|
306
|
+
c = self._grid.corner
|
|
307
|
+
return tuple(c[i] for i in self._grid.spatial)
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def box(self) -> Box:
|
|
311
|
+
"""The array's extent on the spatial axes as a world box, ``[corner, corner + shape *
|
|
312
|
+
voxel_size)``: what funlib calls the array's roi. ``Box.from_index`` gives the same
|
|
313
|
+
over every axis."""
|
|
314
|
+
sp = self._grid.spatial
|
|
315
|
+
return Box.from_index(
|
|
316
|
+
(0,) * len(sp),
|
|
317
|
+
tuple(self._grid.shape[i] for i in sp),
|
|
318
|
+
tuple(self._grid.voxel_size[i] for i in sp),
|
|
319
|
+
tuple(self._grid.translation[i] for i in sp),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def levels(self) -> list[tuple[str, tuple[float, ...]]]:
|
|
324
|
+
"""``(name, spatial voxel size)`` of every level of the pyramid this array belongs to,
|
|
325
|
+
from the group's metadata where it lists them, else by opening each level."""
|
|
326
|
+
return self.levels_pyramid.voxel_sizes()
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def levels_pyramid(self) -> Pyramid:
|
|
330
|
+
if self._pyramid is None:
|
|
331
|
+
self._pyramid = Pyramid(
|
|
332
|
+
self.path, context=self.context, units=self.units, select=self._select
|
|
333
|
+
)
|
|
334
|
+
return self._pyramid
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def level_name(self) -> str:
|
|
338
|
+
return self._ref.name
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def level_path(self) -> str:
|
|
342
|
+
"""The path of the array actually opened: the level under a group, or ``path`` itself
|
|
343
|
+
for an array (``path`` stays what the caller passed). Writers and chunk-key code
|
|
344
|
+
want this one."""
|
|
345
|
+
return self._meta.path
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def remote(self) -> bool:
|
|
349
|
+
return is_remote(self._node.path)
|
|
350
|
+
|
|
351
|
+
def __repr__(self) -> str:
|
|
352
|
+
return (
|
|
353
|
+
f"WorldArray({self.path!r}, level={self.level_name!r}, shape={self.shape}, dtype={self.dtype}, "
|
|
354
|
+
f"axes={self.axes}, voxel_size={self.voxel_size}, translation={self.translation})"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def reopen(self, *, context: ts.Context | None = None, **overrides) -> WorldArray:
|
|
358
|
+
"""The same array on another context (or with other defaults), opened afresh."""
|
|
359
|
+
opts = {
|
|
360
|
+
**self._opts,
|
|
361
|
+
"padding": self.padding,
|
|
362
|
+
"method": self.method,
|
|
363
|
+
"select": self._select,
|
|
364
|
+
"units": self.units,
|
|
365
|
+
"retries": self.retries,
|
|
366
|
+
"transform": self.transform,
|
|
367
|
+
**overrides,
|
|
368
|
+
}
|
|
369
|
+
return WorldArray(
|
|
370
|
+
self.path, context=context if context is not None else self.context, **opts
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# --- index space ------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def chunks_covering(
|
|
376
|
+
self, begin: Sequence[int], end: Sequence[int]
|
|
377
|
+
) -> Iterator[tuple[int, ...]]:
|
|
378
|
+
"""The chunk indices a voxel box touches, clipped to the array."""
|
|
379
|
+
cs = self._meta.chunk_shape
|
|
380
|
+
first = [max(0, int(b)) // c for b, c in zip(begin, cs)]
|
|
381
|
+
last = [(min(int(e), n) - 1) // c for e, n, c in zip(end, self._grid.shape, cs)]
|
|
382
|
+
if any(hi < lo for lo, hi in zip(first, last)):
|
|
383
|
+
return
|
|
384
|
+
for idx in np.ndindex(*[hi - lo + 1 for lo, hi in zip(first, last)]):
|
|
385
|
+
yield tuple(int(i + lo) for i, lo in zip(idx, first))
|
|
386
|
+
|
|
387
|
+
def list_chunks(self) -> list[tuple[int, ...]]:
|
|
388
|
+
"""The chunk indices that exist in storage (C order, the storage grid), by listing the
|
|
389
|
+
array's keys: zarr v2 and v3, N5, and unsharded precomputed. For a sharded zarr v3
|
|
390
|
+
array the storage grid is the shard grid. Works on any kvstore, URLs included."""
|
|
391
|
+
node = self._node
|
|
392
|
+
kv = ts.KvStore.open(self._store.kvstore.spec()).result()
|
|
393
|
+
keys = [k.decode() if isinstance(k, bytes) else str(k) for k in kv.list().result()]
|
|
394
|
+
if node.format == "zarr2":
|
|
395
|
+
sep = node.docs.get(".zarray", {}).get("dimension_separator", ".")
|
|
396
|
+
pattern = re.compile(r"^\d+(?:" + re.escape(sep) + r"\d+)*$")
|
|
397
|
+
found = [tuple(int(p) for p in k.split(sep)) for k in keys if pattern.match(k)]
|
|
398
|
+
elif node.format == "zarr3":
|
|
399
|
+
enc = node.docs.get("zarr.json", {}).get("chunk_key_encoding", {}) or {}
|
|
400
|
+
sep = (enc.get("configuration") or {}).get("separator", "/")
|
|
401
|
+
prefix = "c" if enc.get("name", "default") == "default" else ""
|
|
402
|
+
found = []
|
|
403
|
+
for k in keys:
|
|
404
|
+
parts = k.split(sep)
|
|
405
|
+
if prefix and (not parts or parts[0] != prefix):
|
|
406
|
+
continue
|
|
407
|
+
parts = parts[1:] if prefix else parts
|
|
408
|
+
if parts and all(p.isdigit() for p in parts):
|
|
409
|
+
found.append(tuple(int(p) for p in parts))
|
|
410
|
+
elif node.format == "n5":
|
|
411
|
+
found = [
|
|
412
|
+
tuple(int(p) for p in k.split("/"))[::-1]
|
|
413
|
+
for k in keys
|
|
414
|
+
if k and all(p.isdigit() for p in k.split("/"))
|
|
415
|
+
]
|
|
416
|
+
else: # precomputed, unsharded: "x0-x1_y0-y1_z0-z1"
|
|
417
|
+
scale = node.docs["info"]["scales"][self._ref.scale_index or 0]
|
|
418
|
+
if "sharding" in scale:
|
|
419
|
+
raise NotImplementedError(
|
|
420
|
+
"list_chunks: sharded precomputed stores list shards, not chunks"
|
|
421
|
+
)
|
|
422
|
+
chunk = scale["chunk_sizes"][0]
|
|
423
|
+
found = []
|
|
424
|
+
for k in keys:
|
|
425
|
+
m = re.fullmatch(r"(\d+)-\d+_(\d+)-\d+_(\d+)-\d+", k.rsplit("/", 1)[-1])
|
|
426
|
+
if m:
|
|
427
|
+
starts = [int(m.group(i)) for i in (1, 2, 3)]
|
|
428
|
+
found.append(tuple(s // c for s, c in zip(starts, chunk))[::-1])
|
|
429
|
+
return sorted(set(found))
|
|
430
|
+
|
|
431
|
+
def read_index(
|
|
432
|
+
self,
|
|
433
|
+
begin: Sequence[int],
|
|
434
|
+
end: Sequence[int],
|
|
435
|
+
padding: Any = None,
|
|
436
|
+
mask: bool = False,
|
|
437
|
+
retries: Retry | None = None,
|
|
438
|
+
select: dict[str, int] | None = None,
|
|
439
|
+
transform: Transform | None = None,
|
|
440
|
+
) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
|
|
441
|
+
"""The voxels ``[begin, end)`` on every axis of the stored array, clipped to it, read,
|
|
442
|
+
and padded to the full box with ``padding`` (a value, or ``"edge"`` for the nearest
|
|
443
|
+
voxel). A box entirely outside the array returns the fill without touching storage.
|
|
444
|
+
With ``mask``, also a boolean array that is True inside the array. ``select`` pins
|
|
445
|
+
axes for this read only (``{"c": 1}``): ``begin``/``end`` then cover the remaining
|
|
446
|
+
axes and the result drops the pinned ones. ``transform`` (default ``self.transform``)
|
|
447
|
+
is applied to the stored values before padding, so padding is never transformed."""
|
|
448
|
+
if select:
|
|
449
|
+
return self._read_index_selected(begin, end, select, padding, mask, retries, transform)
|
|
450
|
+
padding = self.padding if padding is None else padding
|
|
451
|
+
transform = self.transform if transform is None else transform
|
|
452
|
+
shape = self._grid.shape
|
|
453
|
+
ndim = len(shape)
|
|
454
|
+
if len(begin) != ndim or len(end) != ndim:
|
|
455
|
+
raise ValueError(f"begin/end must have {ndim} entries, got {list(begin)} / {list(end)}")
|
|
456
|
+
begin = tuple(int(b) for b in begin)
|
|
457
|
+
end = tuple(int(e) for e in end)
|
|
458
|
+
if any(e < b for b, e in zip(begin, end)):
|
|
459
|
+
raise ValueError(f"end {list(end)} is before begin {list(begin)}")
|
|
460
|
+
out_shape = tuple(e - b for b, e in zip(begin, end))
|
|
461
|
+
clip_lo = tuple(max(b, 0) for b in begin)
|
|
462
|
+
clip_hi = tuple(min(e, n) for e, n in zip(end, shape))
|
|
463
|
+
|
|
464
|
+
# the hot path: a box inside the array needs no mask, no fill and no copy
|
|
465
|
+
if clip_lo == begin and clip_hi == end and all(out_shape):
|
|
466
|
+
out = self._read_block(begin, end, retries, transform)
|
|
467
|
+
return (out, np.ones(out_shape, dtype=bool)) if mask else out
|
|
468
|
+
|
|
469
|
+
overlap = all(h > lo for lo, h in zip(clip_lo, clip_hi)) and all(out_shape)
|
|
470
|
+
inner = tuple(slice(a - b, c - b) for a, c, b in zip(clip_lo, clip_hi, begin))
|
|
471
|
+
if padding == "edge":
|
|
472
|
+
# read the overlap, or the nearest one-voxel slab on an axis with none, then repeat the border
|
|
473
|
+
read_lo, read_hi, before, after = [], [], [], []
|
|
474
|
+
for b, e, a, c, n in zip(begin, end, clip_lo, clip_hi, shape):
|
|
475
|
+
if c > a:
|
|
476
|
+
read_lo.append(a)
|
|
477
|
+
read_hi.append(c)
|
|
478
|
+
before.append(a - b)
|
|
479
|
+
after.append(e - c)
|
|
480
|
+
else:
|
|
481
|
+
edge = 0 if e <= 0 else n - 1
|
|
482
|
+
read_lo.append(edge)
|
|
483
|
+
read_hi.append(edge + 1)
|
|
484
|
+
before.append(e - b - 1)
|
|
485
|
+
after.append(0)
|
|
486
|
+
if not all(out_shape):
|
|
487
|
+
out = np.empty(out_shape, dtype=self._grid.dtype)
|
|
488
|
+
else:
|
|
489
|
+
block = self._read_block(read_lo, read_hi, retries, transform)
|
|
490
|
+
out = np.pad(block, list(zip(before, after)), mode="edge")
|
|
491
|
+
else:
|
|
492
|
+
block = self._read_block(clip_lo, clip_hi, retries, transform) if overlap else None
|
|
493
|
+
dtype = block.dtype if block is not None else self._grid.dtype
|
|
494
|
+
out = np.full(out_shape, padding, dtype=dtype)
|
|
495
|
+
if block is not None:
|
|
496
|
+
out[inner] = block
|
|
497
|
+
if not out.flags.c_contiguous:
|
|
498
|
+
out = np.ascontiguousarray(out)
|
|
499
|
+
if mask:
|
|
500
|
+
inside = np.zeros(out_shape, dtype=bool)
|
|
501
|
+
if overlap:
|
|
502
|
+
inside[inner] = True
|
|
503
|
+
return out, inside
|
|
504
|
+
return out
|
|
505
|
+
|
|
506
|
+
def _read_block(
|
|
507
|
+
self,
|
|
508
|
+
lo: Sequence[int],
|
|
509
|
+
hi: Sequence[int],
|
|
510
|
+
retries: Retry | None,
|
|
511
|
+
transform: Transform | None = None,
|
|
512
|
+
) -> np.ndarray:
|
|
513
|
+
view = self._store[tuple(slice(int(a), int(c)) for a, c in zip(lo, hi))]
|
|
514
|
+
data = read_with_retries(view, retries or self.retries, remote=self.remote, what=self.path)
|
|
515
|
+
data = np.asarray(data)
|
|
516
|
+
if not data.dtype.isnative:
|
|
517
|
+
data = data.astype(data.dtype.newbyteorder("="))
|
|
518
|
+
if transform is not None:
|
|
519
|
+
data = np.asarray(transform(data))
|
|
520
|
+
return data
|
|
521
|
+
|
|
522
|
+
def _read_index_selected(self, begin, end, select, padding, mask, retries, transform):
|
|
523
|
+
axes = self._grid.axes
|
|
524
|
+
pinned: dict[int, int] = {}
|
|
525
|
+
for name, index in select.items():
|
|
526
|
+
if name not in axes:
|
|
527
|
+
raise ValueError(f"{self.path}: no axis named {name!r} to select; axes are {axes}")
|
|
528
|
+
pinned[axes.index(name)] = int(index)
|
|
529
|
+
if len(begin) != self._grid.ndim - len(pinned):
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"begin/end must have {self._grid.ndim - len(pinned)} entries with select={select}"
|
|
532
|
+
)
|
|
533
|
+
full_begin, full_end, it = [], [], iter(zip(begin, end))
|
|
534
|
+
for axis in range(self._grid.ndim):
|
|
535
|
+
if axis in pinned:
|
|
536
|
+
full_begin.append(pinned[axis])
|
|
537
|
+
full_end.append(pinned[axis] + 1)
|
|
538
|
+
else:
|
|
539
|
+
b, e = next(it)
|
|
540
|
+
full_begin.append(int(b))
|
|
541
|
+
full_end.append(int(e))
|
|
542
|
+
out = self.read_index(
|
|
543
|
+
full_begin, full_end, padding=padding, mask=mask, retries=retries, transform=transform
|
|
544
|
+
)
|
|
545
|
+
drop = tuple(sorted(pinned))
|
|
546
|
+
if mask:
|
|
547
|
+
return np.squeeze(out[0], axis=drop), np.squeeze(out[1], axis=drop)
|
|
548
|
+
return np.squeeze(out, axis=drop)
|
|
549
|
+
|
|
550
|
+
def index_box(self, box) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
551
|
+
"""The voxel index range a world box selects on the stored grid (Behaviour 2), over
|
|
552
|
+
the spatial axes or every axis; the other axes come back whole. For snapping a
|
|
553
|
+
caller's box to whole voxels before an index read."""
|
|
554
|
+
box = Box.coerce(box)
|
|
555
|
+
spatial, ndim = self._grid.spatial, self._grid.ndim
|
|
556
|
+
if box.ndim == len(spatial):
|
|
557
|
+
axes = list(spatial)
|
|
558
|
+
elif box.ndim == ndim:
|
|
559
|
+
axes = list(range(ndim))
|
|
560
|
+
else:
|
|
561
|
+
raise ValueError(
|
|
562
|
+
f"box has {box.ndim} axes; the array has {ndim} ({len(spatial)} spatial)"
|
|
563
|
+
)
|
|
564
|
+
lo, hi = index_range(
|
|
565
|
+
box.begin,
|
|
566
|
+
box.end,
|
|
567
|
+
[self._grid.voxel_size[a] for a in axes],
|
|
568
|
+
[self._grid.translation[a] for a in axes],
|
|
569
|
+
)
|
|
570
|
+
begin, end = [0] * ndim, list(self._grid.shape)
|
|
571
|
+
for a, b, e in zip(axes, lo, hi):
|
|
572
|
+
begin[a], end[a] = b, e
|
|
573
|
+
return tuple(begin), tuple(end)
|
|
574
|
+
|
|
575
|
+
# --- world space ------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
def read(
|
|
578
|
+
self,
|
|
579
|
+
box,
|
|
580
|
+
voxel_size: Sequence[float] | None = None,
|
|
581
|
+
padding: Any = None,
|
|
582
|
+
method: str | None = None,
|
|
583
|
+
mask: bool = False,
|
|
584
|
+
retries: Retry | None = None,
|
|
585
|
+
strict: bool = False,
|
|
586
|
+
anchor: Sequence[float] | None = None,
|
|
587
|
+
transform: Transform | None = None,
|
|
588
|
+
post_transform: Transform | None = None,
|
|
589
|
+
select: dict[str, int] | None = None,
|
|
590
|
+
) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
|
|
591
|
+
"""The data in a world box, half-open per axis, in ``units``. ``select`` pins
|
|
592
|
+
non-spatial axes for this read only, as it does at open, and drops them from the result.
|
|
593
|
+
|
|
594
|
+
``post_transform`` is applied after resampling (after the cast back to the stored
|
|
595
|
+
dtype) and before padding, for a normalisation that wants the resampled block.
|
|
596
|
+
|
|
597
|
+
The box covers the spatial axes (other axes are read whole, or as ``select`` pinned
|
|
598
|
+
them) or every axis. A voxel is read iff its centre lies in the box. ``voxel_size``
|
|
599
|
+
reads at that size instead (resampling by ``method``); ``anchor`` says where a
|
|
600
|
+
target voxel corner lies (pass another volume's ``corner`` to read onto its grid;
|
|
601
|
+
the source corner, or the ``anchor`` given at open, otherwise); ``transform`` is
|
|
602
|
+
applied to the stored values before resampling and padding; ``strict`` raises for a
|
|
603
|
+
box aligned to neither voxel corners nor voxel centres.
|
|
604
|
+
"""
|
|
605
|
+
box = Box.coerce(box)
|
|
606
|
+
padding = self.padding if padding is None else padding
|
|
607
|
+
method = self.method if method is None else method
|
|
608
|
+
transform = self.transform if transform is None else transform
|
|
609
|
+
target = tuple(float(v) for v in voxel_size) if voxel_size is not None else self._target
|
|
610
|
+
spatial = self._grid.spatial
|
|
611
|
+
ndim = self._grid.ndim
|
|
612
|
+
|
|
613
|
+
if box.ndim == len(spatial):
|
|
614
|
+
axes = list(spatial)
|
|
615
|
+
elif box.ndim == ndim:
|
|
616
|
+
axes = list(range(ndim))
|
|
617
|
+
else:
|
|
618
|
+
raise ValueError(
|
|
619
|
+
f"box has {box.ndim} axes; the array has {ndim} ({len(spatial)} spatial)"
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
source_vs = self._source_voxel_size()
|
|
623
|
+
if target is not None and same_voxel_size(target, source_vs):
|
|
624
|
+
target = None
|
|
625
|
+
|
|
626
|
+
# the grid the box is measured against: the source, or the target grid from the anchor
|
|
627
|
+
vs = list(self._grid.voxel_size)
|
|
628
|
+
tr = list(self._grid.translation)
|
|
629
|
+
origins: list[float] = []
|
|
630
|
+
if target is not None:
|
|
631
|
+
corner = self._grid.corner
|
|
632
|
+
anchors = tuple(float(a) for a in anchor) if anchor is not None else self.anchor
|
|
633
|
+
if len(anchors) != len(spatial):
|
|
634
|
+
raise ValueError(
|
|
635
|
+
f"anchor has {len(anchors)} entries; the array has {len(spatial)} spatial axes"
|
|
636
|
+
)
|
|
637
|
+
for k, axis in enumerate(spatial):
|
|
638
|
+
vs[axis] = target[k]
|
|
639
|
+
tr[axis] = anchors[k] + target[k] / 2
|
|
640
|
+
origins.append((anchors[k] - corner[axis]) / source_vs[k])
|
|
641
|
+
box_vs = [vs[a] for a in axes]
|
|
642
|
+
box_tr = [tr[a] for a in axes]
|
|
643
|
+
if strict and not is_aligned(box.begin, box.end, box_vs, box_tr):
|
|
644
|
+
raise ValueError(
|
|
645
|
+
f"{self.path}: box {box} is aligned to neither voxel corners nor voxel centres"
|
|
646
|
+
)
|
|
647
|
+
lo, hi = index_range(box.begin, box.end, box_vs, box_tr)
|
|
648
|
+
|
|
649
|
+
begin = [0] * ndim
|
|
650
|
+
end = list(self._grid.shape)
|
|
651
|
+
for a, lo_a, hi_a in zip(axes, lo, hi):
|
|
652
|
+
begin[a], end[a] = lo_a, hi_a
|
|
653
|
+
drop: tuple[int, ...] = ()
|
|
654
|
+
if select:
|
|
655
|
+
names = self._grid.axes
|
|
656
|
+
pinned = []
|
|
657
|
+
for name, index in select.items():
|
|
658
|
+
if name not in names:
|
|
659
|
+
raise ValueError(
|
|
660
|
+
f"{self.path}: no axis named {name!r} to select; axes are {names}"
|
|
661
|
+
)
|
|
662
|
+
a = names.index(name)
|
|
663
|
+
begin[a], end[a] = int(index), int(index) + 1
|
|
664
|
+
pinned.append(a)
|
|
665
|
+
drop = tuple(sorted(pinned))
|
|
666
|
+
if drop: # the callables see the array as the caller will, without the pinned axes
|
|
667
|
+
|
|
668
|
+
def _without_pinned(fn):
|
|
669
|
+
return lambda a: np.expand_dims(np.asarray(fn(np.squeeze(a, axis=drop))), drop)
|
|
670
|
+
|
|
671
|
+
transform = _without_pinned(transform) if transform is not None else None
|
|
672
|
+
post_transform = (
|
|
673
|
+
_without_pinned(post_transform) if post_transform is not None else None
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if target is None:
|
|
677
|
+
want_mask = mask or post_transform is not None
|
|
678
|
+
out = self.read_index(
|
|
679
|
+
begin, end, padding=padding, mask=want_mask, retries=retries, transform=transform
|
|
680
|
+
)
|
|
681
|
+
if post_transform is None:
|
|
682
|
+
return _squeeze(out, drop)
|
|
683
|
+
data, inside = out
|
|
684
|
+
data = np.asarray(post_transform(data))
|
|
685
|
+
if padding != "edge":
|
|
686
|
+
data = data.copy()
|
|
687
|
+
data[~inside] = padding
|
|
688
|
+
return _squeeze((data, inside) if mask else data, drop)
|
|
689
|
+
|
|
690
|
+
def read_index(b, e, pad):
|
|
691
|
+
return self.read_index(b, e, padding=pad, retries=retries, transform=transform)
|
|
692
|
+
|
|
693
|
+
out = _resample.resample(
|
|
694
|
+
read_index,
|
|
695
|
+
self._grid.shape,
|
|
696
|
+
self._grid.dtype,
|
|
697
|
+
spatial,
|
|
698
|
+
source_vs,
|
|
699
|
+
target,
|
|
700
|
+
begin,
|
|
701
|
+
end,
|
|
702
|
+
method,
|
|
703
|
+
padding,
|
|
704
|
+
True,
|
|
705
|
+
origins,
|
|
706
|
+
)
|
|
707
|
+
data, inside = out
|
|
708
|
+
if post_transform is not None:
|
|
709
|
+
data = np.asarray(post_transform(data))
|
|
710
|
+
if padding != "edge" and not inside.all():
|
|
711
|
+
data = data.copy()
|
|
712
|
+
data[~inside] = padding
|
|
713
|
+
return _squeeze((data, inside) if mask else data, drop)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _squeeze(out, drop: tuple[int, ...]):
|
|
717
|
+
if not drop:
|
|
718
|
+
return out
|
|
719
|
+
if isinstance(out, tuple):
|
|
720
|
+
return np.squeeze(out[0], axis=drop), np.squeeze(out[1], axis=drop)
|
|
721
|
+
return np.squeeze(out, axis=drop)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class Pyramid:
|
|
725
|
+
"""The levels of a multiscale group (or of the group an array belongs to), each a
|
|
726
|
+
``WorldArray``, opened on first use. Choosing a level by voxel size reads the group's
|
|
727
|
+
own metadata where it lists the levels' scales (OME-NGFF, COSEM N5, precomputed), and
|
|
728
|
+
opens the levels only when it does not."""
|
|
729
|
+
|
|
730
|
+
def __init__(
|
|
731
|
+
self,
|
|
732
|
+
path: str,
|
|
733
|
+
*,
|
|
734
|
+
context: ts.Context | None = None,
|
|
735
|
+
units: Units = "nanometer",
|
|
736
|
+
select: dict[str, int] | None = None,
|
|
737
|
+
_node: Node | None = None,
|
|
738
|
+
**kwargs,
|
|
739
|
+
):
|
|
740
|
+
self.path = path
|
|
741
|
+
self.context = context if context is not None else shared_context(0)
|
|
742
|
+
self.units = units
|
|
743
|
+
self._select = select or {}
|
|
744
|
+
self._kwargs = kwargs
|
|
745
|
+
self._node = _node if _node is not None else detect(path)
|
|
746
|
+
self._refs = discover(self._node)
|
|
747
|
+
if not self._refs:
|
|
748
|
+
raise ValueError(
|
|
749
|
+
f"{path}: no levels found (no multiscales datasets, no s0, no precomputed scales)"
|
|
750
|
+
)
|
|
751
|
+
self._levels: dict[str, WorldArray] = {}
|
|
752
|
+
self._light: list[tuple[str, tuple[float, ...], tuple[float, ...]]] | None = None
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def names(self) -> list[str]:
|
|
756
|
+
return [r.name for r in self._refs]
|
|
757
|
+
|
|
758
|
+
def _ref(self, level: str | int) -> LevelRef:
|
|
759
|
+
"""A level by name, or by position (0 is the first listed, the finest by convention)."""
|
|
760
|
+
if isinstance(level, int) and not isinstance(level, bool):
|
|
761
|
+
if not -len(self._refs) <= level < len(self._refs):
|
|
762
|
+
raise ValueError(
|
|
763
|
+
f"{self.path}: no level {level}; there are {len(self._refs)}: {self.names}"
|
|
764
|
+
)
|
|
765
|
+
return self._refs[level]
|
|
766
|
+
for ref in self._refs:
|
|
767
|
+
if ref.name == str(level):
|
|
768
|
+
return ref
|
|
769
|
+
raise ValueError(f"{self.path}: no level named {level!r}; levels: {self.names}")
|
|
770
|
+
|
|
771
|
+
def _open(self, ref: LevelRef) -> WorldArray:
|
|
772
|
+
if ref.name not in self._levels:
|
|
773
|
+
node = self._node if ref.scale_index is not None else None
|
|
774
|
+
self._levels[ref.name] = WorldArray(
|
|
775
|
+
self.path if ref.scale_index is not None else ref.path,
|
|
776
|
+
context=self.context,
|
|
777
|
+
units=self.units,
|
|
778
|
+
select=self._select,
|
|
779
|
+
_ref=ref,
|
|
780
|
+
_node=node,
|
|
781
|
+
**self._kwargs,
|
|
782
|
+
)
|
|
783
|
+
return self._levels[ref.name]
|
|
784
|
+
|
|
785
|
+
@property
|
|
786
|
+
def levels(self) -> tuple[WorldArray, ...]:
|
|
787
|
+
return tuple(self._open(ref) for ref in self._refs)
|
|
788
|
+
|
|
789
|
+
def by_name(self, level: str | int) -> WorldArray:
|
|
790
|
+
return self._open(self._ref(level))
|
|
791
|
+
|
|
792
|
+
def placements(self) -> list[tuple[str, tuple[float, ...], tuple[float, ...]]]:
|
|
793
|
+
"""``(name, spatial voxel size, spatial translation)`` per level in the requested
|
|
794
|
+
units, from the group's own document when it lists the levels' scales (OME-NGFF,
|
|
795
|
+
COSEM N5, precomputed), else by opening each level."""
|
|
796
|
+
if self._light is None:
|
|
797
|
+
space, _ = _unit_targets(self.units)
|
|
798
|
+
group = self._node if self._node.kind == "group" else None
|
|
799
|
+
if group is None and self._refs and self._refs[0].path != self._node.path:
|
|
800
|
+
group = detect(self._refs[0].path.rsplit("/", 1)[0], missing_ok=True)
|
|
801
|
+
light = light_levels(group, self.names, space) if group is not None else None
|
|
802
|
+
if light is None:
|
|
803
|
+
light = [
|
|
804
|
+
(lvl.level_name, lvl.source_voxel_size, lvl.translation) for lvl in self.levels
|
|
805
|
+
]
|
|
806
|
+
self._light = light
|
|
807
|
+
return self._light
|
|
808
|
+
|
|
809
|
+
def level_meta(self, level: str | int) -> ArrayMeta:
|
|
810
|
+
"""One level's ``ArrayMeta`` in native units from its documents alone, with no
|
|
811
|
+
tensorstore opened (shape, dtype, chunks and placement; a sharded array's inner
|
|
812
|
+
chunk)."""
|
|
813
|
+
ref = self._ref(level)
|
|
814
|
+
conflicts = self._kwargs.get("metadata_conflicts", "error")
|
|
815
|
+
if ref.scale_index is not None:
|
|
816
|
+
return read_meta_light(self._node, scale_index=ref.scale_index, conflicts=conflicts)
|
|
817
|
+
return read_meta_light(detect(ref.path), conflicts=conflicts)
|
|
818
|
+
|
|
819
|
+
def metas(self) -> list[ArrayMeta]:
|
|
820
|
+
"""``level_meta`` for every level, in order."""
|
|
821
|
+
return [self.level_meta(ref.name) for ref in self._refs]
|
|
822
|
+
|
|
823
|
+
def voxel_sizes(self) -> list[tuple[str, tuple[float, ...]]]:
|
|
824
|
+
"""``(name, spatial voxel size)`` per level; see ``placements``."""
|
|
825
|
+
return [(name, vs) for name, vs, _ in self.placements()]
|
|
826
|
+
|
|
827
|
+
def translations(self) -> list[tuple[str, tuple[float, ...]]]:
|
|
828
|
+
"""``(name, spatial translation)`` per level; see ``placements``."""
|
|
829
|
+
return [(name, tr) for name, _, tr in self.placements()]
|
|
830
|
+
|
|
831
|
+
def _select_ref(self, voxel_size, mode: Mode = "floor") -> LevelRef:
|
|
832
|
+
name, _ = select_level(self.voxel_sizes(), voxel_size, mode)
|
|
833
|
+
return self._ref(name)
|
|
834
|
+
|
|
835
|
+
def select(self, voxel_size, mode: Mode = "floor") -> WorldArray:
|
|
836
|
+
return self._open(self._select_ref(voxel_size, mode))
|
|
837
|
+
|
|
838
|
+
def __iter__(self) -> Iterator[WorldArray]:
|
|
839
|
+
return iter(self.levels)
|
|
840
|
+
|
|
841
|
+
def __len__(self) -> int:
|
|
842
|
+
return len(self._refs)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ---------------------------------------------------------------------------
|
|
846
|
+
# Functions
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def open(path: str, **kwargs) -> WorldArray: # noqa: A001
|
|
850
|
+
return WorldArray(path, **kwargs)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def open_pyramid(path: str, **kwargs) -> Pyramid:
|
|
854
|
+
return Pyramid(path, **kwargs)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def metadata(
|
|
858
|
+
path: str, *, level: str | int | None = None, units: Units = None, open: bool = True
|
|
859
|
+
) -> ArrayMeta:
|
|
860
|
+
"""The ``ArrayMeta`` of an array (or of ``level`` under a group), in native units unless
|
|
861
|
+
``units`` says otherwise. ``open=False`` reads it from the documents alone, with no
|
|
862
|
+
tensorstore opened."""
|
|
863
|
+
if open:
|
|
864
|
+
vol = WorldArray(path, level=level, units=units)
|
|
865
|
+
return vol.meta if units is None else vol.grid
|
|
866
|
+
node = detect(path)
|
|
867
|
+
if level == "first":
|
|
868
|
+
level = 0
|
|
869
|
+
if node.format == "precomputed" and (level is not None or node.scale_key is not None):
|
|
870
|
+
wanted = node.scale_key if level is None else level
|
|
871
|
+
meta = read_meta_light(node, scale_index=precomputed_scale_index(node.docs["info"], wanted))
|
|
872
|
+
elif node.kind == "group":
|
|
873
|
+
refs = discover(node)
|
|
874
|
+
if not refs:
|
|
875
|
+
raise ValueError(
|
|
876
|
+
f"{path}: no levels found (no multiscales datasets, no s0, no precomputed scales)"
|
|
877
|
+
)
|
|
878
|
+
if level is None:
|
|
879
|
+
raise ValueError(f"{path} is a group; pass level= one of {[r.name for r in refs]}")
|
|
880
|
+
ref = (
|
|
881
|
+
refs[level]
|
|
882
|
+
if isinstance(level, int)
|
|
883
|
+
else next((r for r in refs if r.name == level), None)
|
|
884
|
+
)
|
|
885
|
+
if ref is None:
|
|
886
|
+
raise ValueError(f"{path}: no level {level!r}; levels: {[r.name for r in refs]}")
|
|
887
|
+
meta = read_meta_light(detect(ref.path))
|
|
888
|
+
else:
|
|
889
|
+
meta = read_meta_light(node)
|
|
890
|
+
if units is None:
|
|
891
|
+
return meta
|
|
892
|
+
space, time = _unit_targets(units)
|
|
893
|
+
return meta.in_units(space=space, time=time)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def open_tensorstore(
|
|
897
|
+
path: str, *, level: str | int | None = None, context: ts.Context | None = None
|
|
898
|
+
) -> ts.TensorStore:
|
|
899
|
+
"""The raw tensorstore of an array or level: C order, origin 0, a precomputed single
|
|
900
|
+
channel dropped. The access fallbacks and shared context apply. No world metadata is
|
|
901
|
+
read, so an array whose placement attributes are odd (a coordinate array, a field) still
|
|
902
|
+
opens."""
|
|
903
|
+
context = context if context is not None else shared_context(0)
|
|
904
|
+
node = detect(path)
|
|
905
|
+
if level == "first":
|
|
906
|
+
level = 0
|
|
907
|
+
if node.format == "precomputed":
|
|
908
|
+
wanted = node.scale_key if level is None else level
|
|
909
|
+
if wanted is None:
|
|
910
|
+
keys = [str(s.get("key", i)) for i, s in enumerate(node.docs["info"]["scales"])]
|
|
911
|
+
raise ValueError(f"{path} is a precomputed volume; pass level= one of {keys}")
|
|
912
|
+
index = (
|
|
913
|
+
wanted
|
|
914
|
+
if isinstance(wanted, int)
|
|
915
|
+
else precomputed_scale_index(node.docs["info"], wanted)
|
|
916
|
+
)
|
|
917
|
+
return open_array(node, context=context, scale_index=index)
|
|
918
|
+
if node.kind == "group":
|
|
919
|
+
refs = discover(node)
|
|
920
|
+
if level is None:
|
|
921
|
+
raise ValueError(f"{path} is a group; pass level= one of {[r.name for r in refs]}")
|
|
922
|
+
ref = (
|
|
923
|
+
refs[level]
|
|
924
|
+
if isinstance(level, int)
|
|
925
|
+
else next((r for r in refs if r.name == level), None)
|
|
926
|
+
)
|
|
927
|
+
if ref is None:
|
|
928
|
+
raise ValueError(f"{path}: no level {level!r}; levels: {[r.name for r in refs]}")
|
|
929
|
+
node = detect(ref.path)
|
|
930
|
+
elif level is not None:
|
|
931
|
+
warnings.warn(f"{path} is an array; level={level!r} is ignored", stacklevel=2)
|
|
932
|
+
return open_array(node, context=context)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
__all__ = ["Pyramid", "WorldArray", "metadata", "open", "open_pyramid", "open_tensorstore", "U"]
|