worldarray 0.2.0__py3-none-any.whl → 0.3.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.
worldarray/__init__.py CHANGED
@@ -2,16 +2,19 @@
2
2
 
3
3
  Open zarr v2, zarr v3, N5 and Neuroglancer precomputed arrays and pyramids by path, know
4
4
  where each sits (voxel size, the centre of voxel 0, units, axes), and read voxel boxes or
5
- world boxes as numpy arrays, padded where the box leaves the data. ``docs/spec.md`` is the
6
- contract; the README is its first five sections.
5
+ world boxes as numpy arrays, padded where the box leaves the data; create placed OME-NGFF
6
+ levels and write them from any number of jobs; plan an output grid and cut it into blocks
7
+ before anything exists. ``docs/spec.md`` is the contract; the README is its first five
8
+ sections.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
10
12
 
11
- from . import store, units, write
13
+ from . import grid, store, units, write
12
14
  from .array import Pyramid, WorldArray, create, metadata, open, open_pyramid, open_tensorstore
13
15
  from .context import capped_context, capped_context_spec, shared_context
14
16
  from .geometry import Box, index_range
17
+ from .grid import Block, Grid, blocks, level_factors
15
18
  from .levels import select_level
16
19
  from .placement import ArrayMeta, corner_to_centre, default_axes
17
20
  from .retry import Retry
@@ -25,19 +28,24 @@ from .write import (
25
28
  parse_chunk_key,
26
29
  )
27
30
 
28
- __version__ = "0.2.0"
31
+ __version__ = "0.3.1"
29
32
 
30
33
  __all__ = [
31
34
  "ArrayMeta",
35
+ "Block",
32
36
  "Box",
37
+ "Grid",
33
38
  "Node",
39
+ "blocks",
34
40
  "chunk_key",
35
41
  "corner_to_centre",
36
42
  "create",
37
43
  "default_axes",
38
44
  "detect",
39
45
  "ensure_group_entry",
46
+ "grid",
40
47
  "index_range",
48
+ "level_factors",
41
49
  "level_placement",
42
50
  "level_shape",
43
51
  "list_chunk_keys",
worldarray/array.py CHANGED
@@ -139,6 +139,8 @@ class WorldArray:
139
139
  self._meta = self._rescaled_to_base(base_voxel_size, base_units)
140
140
  space, time = _unit_targets(units)
141
141
  self._grid = self._meta.in_units(space=space, time=time, strict=strict_units)
142
+ # the stored level's spatial voxel size, before any relabel renames the grid
143
+ self._stored_voxel_size = tuple(self._grid.voxel_size[i] for i in self._grid.spatial)
142
144
 
143
145
  self._target: tuple[float, ...] | None = None
144
146
  if voxel_size is not None:
@@ -299,8 +301,9 @@ class WorldArray:
299
301
 
300
302
  @property
301
303
  def source_voxel_size(self) -> tuple[float, ...]:
302
- """The stored level's voxel size on the spatial axes, in ``units``."""
303
- return self._source_voxel_size()
304
+ """The stored level's voxel size on the spatial axes, in ``units``: what the level
305
+ is, whether reads resample it or ``on_mismatch="relabel"`` renames its grid."""
306
+ return self._stored_voxel_size
304
307
 
305
308
  @property
306
309
  def resampled(self) -> bool:
@@ -337,8 +340,22 @@ class WorldArray:
337
340
  def box(self) -> Box:
338
341
  """The array's extent on the spatial axes as a world box, ``[corner, corner + shape *
339
342
  voxel_size)``: what funlib calls the array's roi. ``Box.from_index`` gives the same
340
- over every axis."""
343
+ over every axis. With a resample target it is the extent of the target voxels that
344
+ ``shape`` counts, which may reach past the stored data by less than one target voxel,
345
+ so ``read(box)`` returns ``shape``."""
341
346
  sp = self._grid.spatial
347
+ if self._target is not None:
348
+ begin, end = [], []
349
+ for k, axis in enumerate(sp):
350
+ first, last = _resample.target_range(
351
+ self._grid.shape[axis],
352
+ self._grid.voxel_size[axis],
353
+ self._target[k],
354
+ self._origins()[k],
355
+ )
356
+ begin.append(self.anchor[k] + first * self._target[k])
357
+ end.append(self.anchor[k] + last * self._target[k])
358
+ return Box(tuple(begin), tuple(end))
342
359
  return Box.from_index(
343
360
  (0,) * len(sp),
344
361
  tuple(self._grid.shape[i] for i in sp),
@@ -551,6 +568,34 @@ class WorldArray:
551
568
  )
552
569
  self.write_index(begin, end, data, partial=partial)
553
570
 
571
+ def blocks(self, block_shape=None, context=0, box=None, clip: bool = True):
572
+ """The array cut into blocks (Behaviour 17): write boxes tiling the stored grid (or a
573
+ voxel ``box``) with ``block_shape`` (default ``write_shape``, so the default blocks are
574
+ aligned writes), read boxes grown by ``context`` and clipped to the array when
575
+ ``clip``. Plain records, nothing scheduled."""
576
+ from .grid import blocks as _blocks
577
+
578
+ return _blocks(
579
+ self._grid.shape,
580
+ self.write_shape if block_shape is None else block_shape,
581
+ context=context,
582
+ box=box,
583
+ clip=clip,
584
+ )
585
+
586
+ def write_chunk(self, index: Sequence[int], data: np.ndarray) -> None:
587
+ """Write the whole write unit at ``index`` on the storage grid (the shard of a sharded
588
+ array); ``data`` has that unit's shape, clipped at the array's end."""
589
+ unit, shape = self.write_shape, self._grid.shape
590
+ index = tuple(int(i) for i in index)
591
+ if len(index) != len(shape):
592
+ raise ValueError(f"{self.path}: index needs one entry per axis of {shape}")
593
+ begin = tuple(i * u for i, u in zip(index, unit))
594
+ if any(i < 0 or b >= n for i, b, n in zip(index, begin, shape)):
595
+ raise ValueError(f"{self.path}: no write unit at {index}")
596
+ end = tuple(min(b + u, n) for b, u, n in zip(begin, unit, shape))
597
+ self.write_index(begin, end, data)
598
+
554
599
  def read_index(
555
600
  self,
556
601
  begin: Sequence[int],
@@ -1051,11 +1096,12 @@ class Pyramid:
1051
1096
 
1052
1097
  def create(
1053
1098
  path: str,
1054
- shape: Sequence[int],
1055
- dtype,
1056
- voxel_size,
1057
- translation,
1099
+ shape: Sequence[int] | None = None,
1100
+ dtype=None,
1101
+ voxel_size=None,
1102
+ translation=None,
1058
1103
  *,
1104
+ grid=None,
1059
1105
  axes: Sequence[str] | None = None,
1060
1106
  units: Units = "nanometer",
1061
1107
  chunks: Sequence[int] | None = None,
@@ -1079,6 +1125,19 @@ def create(
1079
1125
  raises ``FileExistsError``; with ``"adopt"`` an identical array (shape, dtype, chunks,
1080
1126
  shards, placement) is opened for writing and a different one raises ``ValueError``
1081
1127
  naming every difference. Returns a writable ``WorldArray``."""
1128
+ if grid is not None:
1129
+ # a Grid planned the output (Behaviour 18): its facts are the array's, chunks and
1130
+ # shards overridable; the placement arguments must not also be given
1131
+ if any(v is not None for v in (shape, voxel_size, translation, axes)):
1132
+ raise ValueError("pass grid= or shape, voxel_size, translation and axes, not both")
1133
+ shape, voxel_size, translation = grid.shape, grid.voxel_size, grid.translation
1134
+ axes, units = grid.axes, grid.units
1135
+ chunks = grid.chunks if chunks is None else chunks
1136
+ shards = grid.shards if shards is None else shards
1137
+ if shape is None or dtype is None or voxel_size is None or translation is None:
1138
+ raise ValueError(
1139
+ "create needs shape, dtype, voxel_size and translation, or grid= and dtype"
1140
+ )
1082
1141
  if chunks is None:
1083
1142
  raise ValueError("chunks is required: the package never picks a chunk shape")
1084
1143
  ndim = len(shape)
worldarray/grid.py ADDED
@@ -0,0 +1,492 @@
1
+ """Grids and blocks: an output planned, and cut into jobs, before anything is stored.
2
+
3
+ ``blocks(shape, block_shape, context, box, clip)`` tiles ``[0, shape)``, or a voxel
4
+ ``box`` with the block grid anchored at its begin, into write boxes in C order with no
5
+ overlap and no gap, the last block on an axis clipped. Each block's read box is its write
6
+ box grown by ``context``. ``Grid`` is a ``WorldArray`` without a store: the same ``box``,
7
+ ``corner``, ``index_box``, ``chunks_covering``, ``write_shape`` and ``blocks`` from the
8
+ facts alone. ``level_factors`` is the pyramid schedule mesh-n-bone and cellmap-analyze
9
+ share. Nothing here schedules or runs anything (spec, Behaviours 17 to 19).
10
+
11
+ Choices the spec leaves open, made here:
12
+
13
+ - ``box`` may reach past the array on any side (a ROI rounded outward to a factor
14
+ multiple). The write boxes tile ``box`` exactly, outside the array too; ``clip=True``
15
+ clips the **read** boxes to the array, ``[0, shape)``, never to ``box`` and never a write
16
+ box. A write box wholly outside the array then has an empty read box at the array's
17
+ nearest face. ``clip=False`` leaves read boxes unclipped, negative begins included, for a
18
+ padded ``read_index``.
19
+ - ``context`` is one int, one per axis, or a ``(before, after)`` pair per axis; the per-axis
20
+ entries may mix ints and pairs. Negative context raises.
21
+ - ``Blocks`` is a plain frozen dataclass of the five arguments, normalised: ``box`` to a
22
+ pair of int tuples (``[0, shape)`` when none is given), ``context`` to one
23
+ ``(before, after)`` pair per axis. Blocks are computed from their id on demand, so
24
+ ``blocks[id]`` is O(1) and the record pickles to a worker without the blocks. Ids run from
25
+ 0 to ``len - 1``; any other id is an ``IndexError``. ``index_of(begin)`` takes a begin on
26
+ the box-anchored grid.
27
+ - ``Block.at_start`` and ``Block.at_end`` say, per axis, whether the block is the first or
28
+ last on the block grid (the box's edge, or the array's). ``Block.inner`` is the write box
29
+ inside the read box, ``slice(begin - read_begin, end - read_begin)`` per axis, which stays
30
+ a correct offset into a padded read when ``clip=False`` lets ``read_begin`` go below 0.
31
+ - ``Grid`` keeps ``voxel_size`` and ``translation`` with one entry per axis, as
32
+ ``ArrayMeta`` does; given over the spatial axes only, the other axes get 1.0 and 0.0 (the
33
+ short-list rule of Convention 4). ``units`` is kept per axis too: a string is the spatial
34
+ axes' unit and every other axis gets ``""``; a dict by kind gives ``"space"`` to the
35
+ spatial axes and ``"time"`` to an axis named ``t``, ``""`` elsewhere; a sequence is per
36
+ axis. ``shards`` need ``chunks`` and must be a whole number of them on every axis.
37
+ - ``Grid.chunks_covering(begin, end, write=False)`` takes ``write`` as ``WorldArray``'s
38
+ does in 0.2: ``write=True`` runs on ``write_shape``. A grid without that shape raises.
39
+ - ``Grid.meta(dtype)`` has ``format`` None, ``path`` ``""``, ``parent`` None, empty
40
+ ``attrs`` and ``group_attrs``, ``chunk_shape`` the chunks (the shape when none),
41
+ ``axes_named`` True (a created array names its axes), ``placed_by`` ``"ome"`` and
42
+ ``fill_value`` None: a grid has no store, no documents and no fill.
43
+ - ``Grid.from_meta(meta)`` keeps the array's chunks (and its ``shard_shape`` when the meta
44
+ carries one); the target grid at ``voxel_size`` keeps neither, since its chunks are the
45
+ new array's to choose. ``voxel_size`` is in the meta's units.
46
+ - ``level_factors`` compares voxel sizes with ``rtol 1e-6``, as Behaviour 3 does. The stop
47
+ rule is checked before each level, so a ``shape`` already within ``stop_shape`` gives no
48
+ levels and the last level returned is the first that fits. ``ratio`` below 1 raises,
49
+ since no axis would ever qualify.
50
+
51
+ The ``WorldArray`` side of 0.3 (``vol.blocks``, ``vol.write_chunk`` and
52
+ ``wa.create(path, grid=g)``) is wired in with the write module of 0.2; nothing here opens a
53
+ store.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import itertools
59
+ import math
60
+ import operator
61
+ from collections.abc import Iterable, Iterator, Sequence
62
+ from dataclasses import dataclass
63
+
64
+ from .geometry import Box, index_range
65
+ from .placement import ArrayMeta, default_axes, spatial_axes
66
+ from .resample import target_shape
67
+
68
+ RTOL = 1e-6
69
+
70
+
71
+ def _whole(values, what: str) -> tuple[int, ...]:
72
+ values = tuple(values)
73
+ out = tuple(int(v) for v in values)
74
+ if any(o != v for o, v in zip(out, values)):
75
+ raise ValueError(f"{what} must be whole numbers, got {values}")
76
+ return out
77
+
78
+
79
+ def _sized(values, what: str, ndim: int, minimum: int) -> tuple[int, ...]:
80
+ out = _whole(values, what)
81
+ if len(out) != ndim:
82
+ raise ValueError(f"{what} has {len(out)} entries; expected {ndim}")
83
+ if any(v < minimum for v in out):
84
+ raise ValueError(f"{what} entries must be at least {minimum}, got {out}")
85
+ return out
86
+
87
+
88
+ def _context(context, ndim: int) -> tuple[tuple[int, int], ...]:
89
+ """One ``(before, after)`` pair per axis from an int, one int per axis, or a pair per axis."""
90
+ if not isinstance(context, Iterable):
91
+ entries = [context] * ndim
92
+ else:
93
+ entries = list(context)
94
+ if len(entries) != ndim:
95
+ raise ValueError(f"context has {len(entries)} entries; the blocks have {ndim} axes")
96
+ pairs = []
97
+ for e in entries:
98
+ pair = _whole((e, e) if not isinstance(e, Iterable) else e, "context")
99
+ if len(pair) != 2:
100
+ raise ValueError(f"a context entry is an int or a (before, after) pair, got {e!r}")
101
+ if min(pair) < 0:
102
+ raise ValueError(f"context cannot be negative, got {e!r}")
103
+ pairs.append(pair)
104
+ return tuple(pairs)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Blocks
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Block:
113
+ """One block: a plain, picklable record. ``begin``/``end`` is the write box,
114
+ ``read_begin``/``read_end`` the write box grown by the context (clipped to the array
115
+ when the blocks clip)."""
116
+
117
+ index: tuple[int, ...] # position on the block grid
118
+ id: int # linear C-order id
119
+ begin: tuple[int, ...]
120
+ end: tuple[int, ...]
121
+ read_begin: tuple[int, ...]
122
+ read_end: tuple[int, ...]
123
+ at_start: tuple[bool, ...] # per axis: the first block on the grid
124
+ at_end: tuple[bool, ...] # per axis: the last block on the grid
125
+
126
+ @property
127
+ def inner(self) -> tuple[slice, ...]:
128
+ """The write box inside the read box: ``data[block.inner]`` of a read of
129
+ ``[read_begin, read_end)`` is the block's own voxels."""
130
+ return tuple(
131
+ slice(b - rb, e - rb) for b, e, rb in zip(self.begin, self.end, self.read_begin)
132
+ )
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Blocks:
137
+ """The blocks tiling ``[0, shape)``, or ``box``, in C order. A frozen record of its
138
+ arguments that computes each block on demand: ``len``, iteration, ``blocks[id]`` (O(1))
139
+ and ``index_of(begin)``."""
140
+
141
+ shape: tuple[int, ...]
142
+ block_shape: tuple[int, ...]
143
+ context: tuple[tuple[int, int], ...] = 0 # normalised to (before, after) per axis
144
+ box: tuple[tuple[int, ...], tuple[int, ...]] | None = None # normalised; [0, shape) if None
145
+ clip: bool = True
146
+
147
+ def __post_init__(self) -> None:
148
+ shape = _whole(self.shape, "shape")
149
+ if any(s < 0 for s in shape):
150
+ raise ValueError(f"shape cannot be negative, got {shape}")
151
+ n = len(shape)
152
+ block_shape = _sized(self.block_shape, "block_shape", n, 1)
153
+ if self.box is None:
154
+ box = ((0,) * n, shape)
155
+ else:
156
+ b = Box.coerce(self.box)
157
+ if b.ndim != n:
158
+ raise ValueError(f"box has {b.ndim} axes; the blocks have {n}")
159
+ box = (_whole(b.begin, "box"), _whole(b.end, "box"))
160
+ object.__setattr__(self, "shape", shape)
161
+ object.__setattr__(self, "block_shape", block_shape)
162
+ object.__setattr__(self, "context", _context(self.context, n))
163
+ object.__setattr__(self, "box", box)
164
+ object.__setattr__(self, "clip", bool(self.clip))
165
+
166
+ @property
167
+ def _counts(self) -> tuple[int, ...]:
168
+ """Blocks per axis."""
169
+ return tuple(-(-max(e - b, 0) // s) for b, e, s in zip(*self.box, self.block_shape))
170
+
171
+ @property
172
+ def count(self) -> int:
173
+ return math.prod(self._counts)
174
+
175
+ def __len__(self) -> int:
176
+ return self.count
177
+
178
+ def __iter__(self) -> Iterator[Block]:
179
+ counts = self._counts
180
+ for i, index in enumerate(itertools.product(*(range(c) for c in counts))):
181
+ yield self._block(index, i, counts)
182
+
183
+ def __getitem__(self, block_id: int) -> Block:
184
+ i = operator.index(block_id)
185
+ counts = self._counts
186
+ total = math.prod(counts)
187
+ if not 0 <= i < total:
188
+ raise IndexError(f"block id {i} is outside [0, {total})")
189
+ index, rest = [], i
190
+ for c in reversed(counts):
191
+ rest, k = divmod(rest, c)
192
+ index.append(k)
193
+ return self._block(tuple(reversed(index)), i, counts)
194
+
195
+ def index_of(self, begin: Sequence[int]) -> tuple[int, ...]:
196
+ """The block index of the block whose write box starts at ``begin``."""
197
+ begin = tuple(begin)
198
+ if len(begin) != len(self.shape):
199
+ raise ValueError(f"begin has {len(begin)} entries; the blocks have {len(self.shape)}")
200
+ index = []
201
+ for b, b0, s, c in zip(begin, self.box[0], self.block_shape, self._counts):
202
+ k, r = divmod(b - b0, s)
203
+ if r != 0 or not 0 <= k < c:
204
+ raise ValueError(
205
+ f"{begin} is not a block's first voxel: the blocks start at {self.box[0]} "
206
+ f"in steps of {self.block_shape}, {self._counts} per axis"
207
+ )
208
+ index.append(int(k))
209
+ return tuple(index)
210
+
211
+ def _block(self, index: tuple[int, ...], block_id: int, counts: tuple[int, ...]) -> Block:
212
+ origin, stop = self.box
213
+ begin = tuple(o + k * s for o, k, s in zip(origin, index, self.block_shape))
214
+ end = tuple(min(b + s, e) for b, s, e in zip(begin, self.block_shape, stop))
215
+ read_begin = tuple(b - c[0] for b, c in zip(begin, self.context))
216
+ read_end = tuple(e + c[1] for e, c in zip(end, self.context))
217
+ if self.clip:
218
+ read_begin = tuple(min(max(r, 0), n) for r, n in zip(read_begin, self.shape))
219
+ read_end = tuple(
220
+ max(min(r, n), lo) for r, n, lo in zip(read_end, self.shape, read_begin)
221
+ )
222
+ return Block(
223
+ index=tuple(index),
224
+ id=block_id,
225
+ begin=begin,
226
+ end=end,
227
+ read_begin=read_begin,
228
+ read_end=read_end,
229
+ at_start=tuple(k == 0 for k in index),
230
+ at_end=tuple(k == c - 1 for k, c in zip(index, counts)),
231
+ )
232
+
233
+
234
+ def blocks(shape, block_shape, context=0, box=None, clip: bool = True) -> Blocks:
235
+ """The blocks tiling ``[0, shape)``, or the voxel ``box`` (anchored at its begin), by
236
+ ``block_shape``, each read box grown by ``context`` and clipped to the array when
237
+ ``clip`` (Behaviour 17)."""
238
+ return Blocks(shape, block_shape, context, box, clip)
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Grids
243
+
244
+
245
+ def _per_axis(values, what: str, ndim: int, spatial: tuple[int, ...], other: float):
246
+ values = tuple(float(v) for v in values)
247
+ if len(values) == ndim:
248
+ return values
249
+ if len(values) == len(spatial):
250
+ out = [other] * ndim
251
+ for k, axis in enumerate(spatial):
252
+ out[axis] = values[k]
253
+ return tuple(out)
254
+ raise ValueError(
255
+ f"{what} has {len(values)} entries; expected {len(spatial)} (spatial) or {ndim}"
256
+ )
257
+
258
+
259
+ def _axis_units(units, axes: tuple[str, ...], spatial: tuple[int, ...]) -> tuple[str, ...]:
260
+ sp = set(spatial)
261
+ if isinstance(units, str):
262
+ return tuple(units if i in sp else "" for i in range(len(axes)))
263
+ if isinstance(units, dict):
264
+ unknown = set(units) - {"space", "time"}
265
+ if unknown:
266
+ raise ValueError(f"units by kind take 'space' and 'time', got {sorted(unknown)}")
267
+ return tuple(
268
+ units.get("space", "") if i in sp else units.get("time", "") if a == "t" else ""
269
+ for i, a in enumerate(axes)
270
+ )
271
+ units = tuple(str(u) for u in units)
272
+ if len(units) != len(axes):
273
+ raise ValueError(f"units has {len(units)} entries; expected {len(axes)}")
274
+ return units
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class Grid:
279
+ """A store-less grid: ``shape`` voxels of ``voxel_size`` with voxel 0 centred at
280
+ ``translation`` (in ``units``), optionally chunked and sharded. Answers what a
281
+ ``WorldArray`` of the same facts answers (Behaviour 18)."""
282
+
283
+ shape: tuple[int, ...]
284
+ voxel_size: tuple[float, ...] # one entry per axis once constructed
285
+ translation: tuple[float, ...] # the CENTRE of voxel 0, one entry per axis
286
+ axes: tuple[str, ...] | None = None # default_axes(ndim) when None
287
+ units: str | dict | tuple[str, ...] = "nanometer" # one string per axis once constructed
288
+ chunks: tuple[int, ...] | None = None
289
+ shards: tuple[int, ...] | None = None
290
+
291
+ def __post_init__(self) -> None:
292
+ shape = _whole(self.shape, "shape")
293
+ if any(s < 0 for s in shape):
294
+ raise ValueError(f"shape cannot be negative, got {shape}")
295
+ n = len(shape)
296
+ axes = default_axes(n) if self.axes is None else tuple(str(a) for a in self.axes)
297
+ if len(axes) != n:
298
+ raise ValueError(f"axes has {len(axes)} entries; the shape has {n}")
299
+ spatial = spatial_axes(axes)
300
+ voxel_size = _per_axis(self.voxel_size, "voxel_size", n, spatial, 1.0)
301
+ if any(v <= 0 for v in voxel_size):
302
+ raise ValueError(f"voxel_size must be positive, got {voxel_size}")
303
+ translation = _per_axis(self.translation, "translation", n, spatial, 0.0)
304
+ chunks = None if self.chunks is None else _sized(self.chunks, "chunks", n, 1)
305
+ shards = None if self.shards is None else _sized(self.shards, "shards", n, 1)
306
+ if shards is not None:
307
+ if chunks is None:
308
+ raise ValueError("shards need chunks: a shard is a whole number of chunks")
309
+ if any(s % c for s, c in zip(shards, chunks)):
310
+ raise ValueError(f"shards {shards} are not a whole number of chunks {chunks}")
311
+ object.__setattr__(self, "shape", shape)
312
+ object.__setattr__(self, "axes", axes)
313
+ object.__setattr__(self, "voxel_size", voxel_size)
314
+ object.__setattr__(self, "translation", translation)
315
+ object.__setattr__(self, "units", _axis_units(self.units, axes, spatial))
316
+ object.__setattr__(self, "chunks", chunks)
317
+ object.__setattr__(self, "shards", shards)
318
+
319
+ @classmethod
320
+ def from_meta(cls, meta: ArrayMeta, voxel_size: Sequence[float] | None = None) -> Grid:
321
+ """The grid of an existing array; with ``voxel_size`` (per spatial axis, in the meta's
322
+ units), Behaviour 4's target grid over it: anchored at its corner, ``ceil(n * s / t)``
323
+ voxels per spatial axis, voxel 0 centred at ``corner + t / 2``, so a
324
+ ``read(voxel_size=t)`` of the source fills it exactly."""
325
+ if voxel_size is None:
326
+ return cls(
327
+ meta.shape,
328
+ meta.voxel_size,
329
+ meta.translation,
330
+ meta.axes,
331
+ meta.units,
332
+ meta.chunk_shape,
333
+ getattr(meta, "shard_shape", None),
334
+ )
335
+ target = tuple(float(v) for v in voxel_size)
336
+ spatial = meta.spatial
337
+ if len(target) != len(spatial):
338
+ raise ValueError(
339
+ f"voxel_size has {len(target)} entries; the array has {len(spatial)} spatial axes"
340
+ )
341
+ shape, vs, tr = list(meta.shape), list(meta.voxel_size), list(meta.translation)
342
+ corner = meta.corner
343
+ for k, axis in enumerate(spatial):
344
+ shape[axis] = target_shape(meta.shape[axis], meta.voxel_size[axis], target[k])
345
+ vs[axis] = target[k]
346
+ tr[axis] = corner[axis] + target[k] / 2
347
+ return cls(tuple(shape), tuple(vs), tuple(tr), meta.axes, meta.units)
348
+
349
+ @property
350
+ def ndim(self) -> int:
351
+ return len(self.shape)
352
+
353
+ @property
354
+ def spatial(self) -> tuple[int, ...]:
355
+ """Indices of the axes named z, y or x when any are; else the last three."""
356
+ return spatial_axes(self.axes)
357
+
358
+ @property
359
+ def corner(self) -> tuple[float, ...]:
360
+ """``translation - voxel_size / 2`` on the spatial axes."""
361
+ return tuple(self.translation[i] - self.voxel_size[i] / 2 for i in self.spatial)
362
+
363
+ @property
364
+ def box(self) -> Box:
365
+ """The grid's extent on the spatial axes, ``[corner, corner + shape * voxel_size)``."""
366
+ sp = self.spatial
367
+ return Box.from_index(
368
+ (0,) * len(sp),
369
+ tuple(self.shape[i] for i in sp),
370
+ tuple(self.voxel_size[i] for i in sp),
371
+ tuple(self.translation[i] for i in sp),
372
+ )
373
+
374
+ def index_box(self, box) -> tuple[tuple[int, ...], tuple[int, ...]]:
375
+ """The voxel index range a world box selects (Behaviour 2's centre rule), over the
376
+ spatial axes or every axis; the other axes come back whole."""
377
+ box = Box.coerce(box)
378
+ spatial, ndim = self.spatial, self.ndim
379
+ if box.ndim == len(spatial):
380
+ axes = list(spatial)
381
+ elif box.ndim == ndim:
382
+ axes = list(range(ndim))
383
+ else:
384
+ raise ValueError(
385
+ f"box has {box.ndim} axes; the grid has {ndim} ({len(spatial)} spatial)"
386
+ )
387
+ lo, hi = index_range(
388
+ box.begin,
389
+ box.end,
390
+ [self.voxel_size[a] for a in axes],
391
+ [self.translation[a] for a in axes],
392
+ )
393
+ begin, end = [0] * ndim, list(self.shape)
394
+ for a, b, e in zip(axes, lo, hi):
395
+ begin[a], end[a] = b, e
396
+ return tuple(begin), tuple(end)
397
+
398
+ @property
399
+ def write_shape(self) -> tuple[int, ...] | None:
400
+ """The unit one writer may own: the shards when given, else the chunks; None when
401
+ the grid has neither."""
402
+ return self.shards if self.shards is not None else self.chunks
403
+
404
+ def chunks_covering(
405
+ self, begin: Sequence[int], end: Sequence[int], write: bool = False
406
+ ) -> Iterator[tuple[int, ...]]:
407
+ """The chunk indices a voxel box touches, clipped to the grid; ``write=True`` on the
408
+ write-unit grid."""
409
+ cs = self.write_shape if write else self.chunks
410
+ if cs is None:
411
+ raise ValueError(f"this grid has no {'write_shape' if write else 'chunks'}")
412
+ first = [max(0, int(b)) // c for b, c in zip(begin, cs)]
413
+ last = [(min(int(e), n) - 1) // c for e, n, c in zip(end, self.shape, cs)]
414
+ if any(hi < lo for lo, hi in zip(first, last)):
415
+ return iter(())
416
+ return (
417
+ tuple(int(i + lo) for i, lo in zip(idx, first))
418
+ for idx in itertools.product(*(range(hi - lo + 1) for lo, hi in zip(first, last)))
419
+ )
420
+
421
+ def blocks(self, block_shape=None, context=0, box=None, clip: bool = True) -> Blocks:
422
+ """``blocks`` over this grid's shape; ``block_shape`` defaults to ``write_shape``, so
423
+ default blocks are aligned writes."""
424
+ if block_shape is None:
425
+ block_shape = self.write_shape
426
+ if block_shape is None:
427
+ raise ValueError("this grid has no chunks or shards; pass block_shape")
428
+ return Blocks(self.shape, block_shape, context, box, clip)
429
+
430
+ def meta(self, dtype) -> ArrayMeta:
431
+ """The ``ArrayMeta`` an array created from this grid reports, placed by OME."""
432
+ return ArrayMeta(
433
+ shape=self.shape,
434
+ dtype=dtype,
435
+ chunk_shape=self.chunks if self.chunks is not None else self.shape,
436
+ axes=self.axes,
437
+ axes_named=True,
438
+ units=self.units,
439
+ voxel_size=self.voxel_size,
440
+ translation=self.translation,
441
+ format=None,
442
+ path="",
443
+ placed_by="ome",
444
+ shard_shape=self.shards,
445
+ )
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # Schedules
450
+
451
+
452
+ def level_factors(
453
+ voxel_size: Sequence[float],
454
+ ratio: float = 1.5,
455
+ max_levels: int | None = None,
456
+ stop_shape: Sequence[int] | None = None,
457
+ shape: Sequence[int] | None = None,
458
+ ) -> list[tuple[int, ...]]:
459
+ """Per level, the per-axis downsampling factors from the level before: 2 on every axis
460
+ whose current voxel size is within ``ratio`` of the smallest current one, 1 elsewhere,
461
+ compounding level over level, until ``max_levels`` levels or until a level's shape
462
+ (``shape`` ceil-divided by the cumulative factors) fits within ``stop_shape`` on every
463
+ axis. Voxel size (30, 8, 8) gives (1, 2, 2), (1, 2, 2), then (2, 2, 2) on: z waits until
464
+ y and x reach 32. Isotropic data gives all 2s (mesh-n-bone's schedule, Behaviour 19)."""
465
+ current = [float(v) for v in voxel_size]
466
+ n = len(current)
467
+ if n == 0 or any(v <= 0 for v in current):
468
+ raise ValueError(f"voxel_size must be positive, got {tuple(voxel_size)}")
469
+ if ratio < 1:
470
+ raise ValueError(f"ratio must be at least 1, got {ratio}")
471
+ if max_levels is None and stop_shape is None:
472
+ raise ValueError("pass max_levels, or stop_shape with shape")
473
+ if max_levels is not None and max_levels < 0:
474
+ raise ValueError(f"max_levels cannot be negative, got {max_levels}")
475
+ if stop_shape is not None:
476
+ if shape is None:
477
+ raise ValueError("stop_shape needs shape")
478
+ stop_shape = _sized(stop_shape, "stop_shape", n, 1)
479
+ shape = _sized(shape, "shape", n, 0)
480
+ cumulative = [1] * n
481
+ out: list[tuple[int, ...]] = []
482
+ while max_levels is None or len(out) < max_levels:
483
+ if stop_shape is not None and all(
484
+ -(-s // c) <= t for s, c, t in zip(shape, cumulative, stop_shape)
485
+ ):
486
+ break
487
+ limit = ratio * min(current) * (1 + RTOL)
488
+ step = tuple(2 if v <= limit else 1 for v in current)
489
+ out.append(step)
490
+ current = [v * f for v, f in zip(current, step)]
491
+ cumulative = [c * f for c, f in zip(cumulative, step)]
492
+ return out
worldarray/resample.py CHANGED
@@ -153,14 +153,22 @@ def apply_axis(block: np.ndarray, axis: int, plan: AxisPlan) -> np.ndarray:
153
153
  raise ValueError(plan.kind)
154
154
 
155
155
 
156
+ def target_range(n: int, s: float, t: float, origin: float = 0.0) -> tuple[int, int]:
157
+ """``[first, last)``: the target voxels of size ``t`` that overlap ``n`` source voxels of
158
+ size ``s``, indexed on a target grid whose voxel 0 corner sits ``origin`` source voxels
159
+ from the source corner."""
160
+ ratio = t / s
161
+ first = int(np.floor(snap_integral(-origin / ratio)))
162
+ last = int(np.ceil(snap_integral((n - origin) / ratio)))
163
+ return first, max(last, first)
164
+
165
+
156
166
  def target_shape(n: int, s: float, t: float, origin: float = 0.0) -> int:
157
167
  """How many target voxels of size ``t`` cover ``n`` source voxels of size ``s``, counting
158
168
  from a target grid whose voxel 0 corner sits ``origin`` source voxels from the source
159
169
  corner: the first and last target voxels that overlap the data, inclusive."""
160
- ratio = t / s
161
- first = np.floor(snap_integral(-origin / ratio))
162
- last = np.ceil(snap_integral((n - origin) / ratio))
163
- return int(max(last - first, 0))
170
+ first, last = target_range(n, s, t, origin)
171
+ return last - first
164
172
 
165
173
 
166
174
  def resample(
worldarray/store.py CHANGED
@@ -77,10 +77,15 @@ GUARD_GCE_METADATA = True # see kvstore_specs
77
77
 
78
78
 
79
79
  def basename(path: str) -> str:
80
+ """The last component of a path or URL (``s3://b/a/s0`` gives ``s0``); a trailing slash
81
+ is dropped first."""
80
82
  return normalize_path(path).rsplit("/", 1)[-1]
81
83
 
82
84
 
83
85
  def join(base: str, *parts: str) -> str:
86
+ """``base`` and ``parts`` joined by single slashes, keeping a URL's scheme
87
+ (``join("s3://b/a/", "s0")`` gives ``s3://b/a/s0``). Public, with ``parent`` and
88
+ ``basename``; like them it normalises ``base`` first (a local path made absolute)."""
84
89
  return "/".join([normalize_path(base).rstrip("/"), *(p.strip("/") for p in parts)])
85
90
 
86
91
 
worldarray/units.py CHANGED
@@ -19,6 +19,7 @@ LENGTH_M = {
19
19
  "millimeter": 1e-3, "millimetre": 1e-3, "mm": 1e-3,
20
20
  "centimeter": 1e-2, "centimetre": 1e-2, "cm": 1e-2,
21
21
  "meter": 1.0, "metre": 1.0, "m": 1.0,
22
+ "kilometer": 1e3, "kilometre": 1e3, "km": 1e3,
22
23
  } # fmt: skip
23
24
  TIME_S = {
24
25
  "nanosecond": 1e-9, "ns": 1e-9,
@@ -77,11 +78,33 @@ def factor(
77
78
  return a[1] / b[1]
78
79
 
79
80
 
81
+ # The OME short form of each unit, by its full name; every spelling of a unit reaches its
82
+ # entry through the table above, so "nanometre", "nm" and "Nanometers" agree.
83
+ _SHORT = {
84
+ "picometer": "pm", "angstrom": "Å", "nanometer": "nm", "micrometer": "µm",
85
+ "millimeter": "mm", "centimeter": "cm", "meter": "m", "kilometer": "km",
86
+ "nanosecond": "ns", "microsecond": "µs", "millisecond": "ms", "second": "s",
87
+ "minute": "min", "hour": "h", "day": "d",
88
+ } # fmt: skip
89
+ _SHORT_BY_SIZE = {_lookup(name): short for name, short in _SHORT.items()}
90
+
91
+
92
+ def abbreviation(unit: str) -> str:
93
+ """The OME short form of a unit: ``"nm"`` for any spelling of nanometre the table knows,
94
+ ``"µm"`` (U+00B5) for any spelling of micrometre, ``"Å"``, ``"s"``, ``"min"`` and so
95
+ on. A unit the table does not know (``""``, ``"pixel"``) is a ``ValueError``."""
96
+ found = _lookup(unit) if unit else None
97
+ if found is None or found not in _SHORT_BY_SIZE:
98
+ raise ValueError(f"unknown unit {unit!r}")
99
+ return _SHORT_BY_SIZE[found]
100
+
101
+
80
102
  # OME-NGFF's name for each unit the table knows, by (kind, size): every spelling of a unit
81
103
  # reaches its entry through the table above, so "nanometre", "nm" and "Nanometers" agree.
82
104
  # The American spelling is OME's.
83
105
  _OME_NAMES = (
84
106
  "picometer", "angstrom", "nanometer", "micrometer", "millimeter", "centimeter", "meter",
107
+ "kilometer",
85
108
  "nanosecond", "microsecond", "millisecond", "second", "minute", "hour", "day",
86
109
  ) # fmt: skip
87
110
  _OME_BY_SIZE = {_lookup(name): name for name in _OME_NAMES}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
5
5
  Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
6
6
  Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
@@ -156,13 +156,27 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
156
156
  and only two answers to "it exists": raise, or adopt an identical array after checking;
157
157
  whole-unit writes from many processes with no coordination; `add_level` as metadata plus
158
158
  an empty array; chunk keys for every format. Nothing is ever deleted or overwritten.
159
+ - **Plans outputs and blocks** (0.3): a store-less `Grid` with the same geometry as an
160
+ array, so an output is planned, cut into blocks and only then created; `blocks` tiling an
161
+ array or a box into write boxes with context-grown read boxes, in C order, as plain
162
+ picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule.
163
+ Nothing is scheduled or run.
159
164
 
160
165
  ## The placement rule
161
166
 
162
167
  A translation is where the centre of voxel 0 is. Index `i` sits at
163
- `translation + i * voxel_size`; voxel `i` covers half a voxel either side. That is how
164
- OME-NGFF defines it and how Neuroglancer draws it. Dialects that store the corner are
165
- moved half a voxel on reading:
168
+ `translation + i * voxel_size` and covers half a voxel either side, so the array's corner
169
+ is `corner = translation - voxel_size / 2`. With 8 nm voxels and the corner at 0, the
170
+ translation is 4: voxel 0 covers 0 to 8 nm around its centre at 4, voxel 1 covers 8 to
171
+ 16 nm around 12. That is how OME-NGFF defines it and how Neuroglancer draws it.
172
+
173
+ The centre is the model because a voxel is a sample taken at a point. Everything that
174
+ compares or resamples grids (picking a level, reading one volume onto another's grid,
175
+ interpolating) works on those points, and the box a voxel covers follows from its point.
176
+ The read rule follows too: a voxel is read iff its centre lies in the box, so boxes that
177
+ adjoin never share a voxel or drop one.
178
+
179
+ Dialects that store the corner are moved half a voxel on reading:
166
180
 
167
181
  | Stored | Means | Read as `translation` |
168
182
  |---|---|---|
@@ -202,6 +216,14 @@ wa.ome_multiscales(axes, units, levels, version="0.5", type=None) ; wa.level_pla
202
216
  wa.level_shape(shape, factors, origin=0) ; wa.chunk_key(index, format, ...) ; wa.parse_chunk_key(key, format, ...)
203
217
  wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="nanometer") # list an array you made yourself
204
218
  wa.units.ome_name("nm") -> "nanometer" ; wa.write.codecs_of(path) -> (codecs, shards)
219
+
220
+ # grids and blocks (0.3)
221
+ g = wa.Grid(shape, voxel_size, translation, axes=None, units="nanometer", chunks=None, shards=None) # or Grid.from_meta(meta, voxel_size=)
222
+ wa.create(path, grid=g, dtype="uint64")
223
+ for b in wa.blocks(shape, block_shape, context=0, box=None, clip=True): # also vol.blocks() and g.blocks()
224
+ b.begin, b.end, b.read_begin, b.read_end, b.inner, b.at_end, b.id # plain records; blocks[id] is O(1)
225
+ wa.level_factors(voxel_size, ratio=1.5, max_levels=None, stop_shape=None, shape=None)
226
+ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent(path) ; wa.store.basename(path)
205
227
  ```
206
228
 
207
229
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
@@ -0,0 +1,16 @@
1
+ worldarray/__init__.py,sha256=BOPB6VfL8LEEEOFaoLKccTDHwOltJwodB3p6e5WGQaQ,1828
2
+ worldarray/array.py,sha256=vuqSl-y018R3czluw1DPQ14VmQf9kyUHJq_CBmu3vBM,57073
3
+ worldarray/context.py,sha256=MZQoHLyir1xNYCHtQPGTIQXaFzFr3oAcyfddrE_UWIA,3284
4
+ worldarray/geometry.py,sha256=ByQu8WrcUydKU2eDsugKwvx1LpQbETmhutfy5mv9inI,7449
5
+ worldarray/grid.py,sha256=ATa6rcWygQ9Pigvqftz1tV_0U6enA6xF4WA_Y2ubD6Y,22313
6
+ worldarray/levels.py,sha256=ICtax6A5Qi7DDlZI2S5UPg-S8Ze-AZoMxXyRykcs36A,6414
7
+ worldarray/placement.py,sha256=l7kPkS6GatjoD_Eb7pJqkkxsHLKLUr5gA9JrxeMsnxo,34140
8
+ worldarray/resample.py,sha256=S6J6SSp6wK4KRzp7tSA94tFRDaPhee8GS-gPKEcOTRU,10796
9
+ worldarray/retry.py,sha256=Uw4YP-tPwtg0tgpBzY0ux4F5vehdTZTp0385QH_vvrk,2766
10
+ worldarray/store.py,sha256=432iIX35ak_cHhibHcZd80ehH0MgN35rtxOtYU35LYg,19154
11
+ worldarray/units.py,sha256=hng7Q8J71jsacPsnmtYyag8ufR0S6xhaL6R38wepvTA,5325
12
+ worldarray/write.py,sha256=IjV2jtwSBBAcWKHfms9KPUALX5GDs7v-2FRpTMO2Gfo,33442
13
+ worldarray-0.3.1.dist-info/METADATA,sha256=huGv6XXY72NQxhEpPrce1L3hihzBgJGMJySWp8BTcak,15877
14
+ worldarray-0.3.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ worldarray-0.3.1.dist-info/licenses/LICENSE,sha256=NsviVkqc3M7YUSeSNmisDSg3e3L8S4ofeIjthWm8pvk,1518
16
+ worldarray-0.3.1.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- worldarray/__init__.py,sha256=NEoYvsC31iUBYD0cKQrcEgBYq_DHaRtL09yXYNdfR08,1560
2
- worldarray/array.py,sha256=Wh6-oW8UN-yVBurGCZjmkrvjTrzTiD0aBTfn7UzsaJE,53722
3
- worldarray/context.py,sha256=MZQoHLyir1xNYCHtQPGTIQXaFzFr3oAcyfddrE_UWIA,3284
4
- worldarray/geometry.py,sha256=ByQu8WrcUydKU2eDsugKwvx1LpQbETmhutfy5mv9inI,7449
5
- worldarray/levels.py,sha256=ICtax6A5Qi7DDlZI2S5UPg-S8Ze-AZoMxXyRykcs36A,6414
6
- worldarray/placement.py,sha256=l7kPkS6GatjoD_Eb7pJqkkxsHLKLUr5gA9JrxeMsnxo,34140
7
- worldarray/resample.py,sha256=nAls-m0vchrsua0q_1yx1K9-djlAnWtkgokCfXqbpIQ,10411
8
- worldarray/retry.py,sha256=Uw4YP-tPwtg0tgpBzY0ux4F5vehdTZTp0385QH_vvrk,2766
9
- worldarray/store.py,sha256=fYLKiy-w8Aa0GxJC8lFCAXx3Etq_2R7i_hyHQxefpyM,18783
10
- worldarray/units.py,sha256=Ma4O6QIvG9RolCVu8YEFT2IQSANmuUWOBb0NOR3Dr6k,4213
11
- worldarray/write.py,sha256=IjV2jtwSBBAcWKHfms9KPUALX5GDs7v-2FRpTMO2Gfo,33442
12
- worldarray-0.2.0.dist-info/METADATA,sha256=yAZBBAe_9mfdnK1TbDGKng0PwI7CKkR0CiukEk3dTeU,14274
13
- worldarray-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
- worldarray-0.2.0.dist-info/licenses/LICENSE,sha256=NsviVkqc3M7YUSeSNmisDSg3e3L8S4ofeIjthWm8pvk,1518
15
- worldarray-0.2.0.dist-info/RECORD,,