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/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"]