worldarray 0.2.0__py3-none-any.whl → 0.3.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 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.0"
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
@@ -551,6 +551,34 @@ class WorldArray:
551
551
  )
552
552
  self.write_index(begin, end, data, partial=partial)
553
553
 
554
+ def blocks(self, block_shape=None, context=0, box=None, clip: bool = True):
555
+ """The array cut into blocks (Behaviour 17): write boxes tiling the stored grid (or a
556
+ voxel ``box``) with ``block_shape`` (default ``write_shape``, so the default blocks are
557
+ aligned writes), read boxes grown by ``context`` and clipped to the array when
558
+ ``clip``. Plain records, nothing scheduled."""
559
+ from .grid import blocks as _blocks
560
+
561
+ return _blocks(
562
+ self._grid.shape,
563
+ self.write_shape if block_shape is None else block_shape,
564
+ context=context,
565
+ box=box,
566
+ clip=clip,
567
+ )
568
+
569
+ def write_chunk(self, index: Sequence[int], data: np.ndarray) -> None:
570
+ """Write the whole write unit at ``index`` on the storage grid (the shard of a sharded
571
+ array); ``data`` has that unit's shape, clipped at the array's end."""
572
+ unit, shape = self.write_shape, self._grid.shape
573
+ index = tuple(int(i) for i in index)
574
+ if len(index) != len(shape):
575
+ raise ValueError(f"{self.path}: index needs one entry per axis of {shape}")
576
+ begin = tuple(i * u for i, u in zip(index, unit))
577
+ if any(i < 0 or b >= n for i, b, n in zip(index, begin, shape)):
578
+ raise ValueError(f"{self.path}: no write unit at {index}")
579
+ end = tuple(min(b + u, n) for b, u, n in zip(begin, unit, shape))
580
+ self.write_index(begin, end, data)
581
+
554
582
  def read_index(
555
583
  self,
556
584
  begin: Sequence[int],
@@ -1051,11 +1079,12 @@ class Pyramid:
1051
1079
 
1052
1080
  def create(
1053
1081
  path: str,
1054
- shape: Sequence[int],
1055
- dtype,
1056
- voxel_size,
1057
- translation,
1082
+ shape: Sequence[int] | None = None,
1083
+ dtype=None,
1084
+ voxel_size=None,
1085
+ translation=None,
1058
1086
  *,
1087
+ grid=None,
1059
1088
  axes: Sequence[str] | None = None,
1060
1089
  units: Units = "nanometer",
1061
1090
  chunks: Sequence[int] | None = None,
@@ -1079,6 +1108,19 @@ def create(
1079
1108
  raises ``FileExistsError``; with ``"adopt"`` an identical array (shape, dtype, chunks,
1080
1109
  shards, placement) is opened for writing and a different one raises ``ValueError``
1081
1110
  naming every difference. Returns a writable ``WorldArray``."""
1111
+ if grid is not None:
1112
+ # a Grid planned the output (Behaviour 18): its facts are the array's, chunks and
1113
+ # shards overridable; the placement arguments must not also be given
1114
+ if any(v is not None for v in (shape, voxel_size, translation, axes)):
1115
+ raise ValueError("pass grid= or shape, voxel_size, translation and axes, not both")
1116
+ shape, voxel_size, translation = grid.shape, grid.voxel_size, grid.translation
1117
+ axes, units = grid.axes, grid.units
1118
+ chunks = grid.chunks if chunks is None else chunks
1119
+ shards = grid.shards if shards is None else shards
1120
+ if shape is None or dtype is None or voxel_size is None or translation is None:
1121
+ raise ValueError(
1122
+ "create needs shape, dtype, voxel_size and translation, or grid= and dtype"
1123
+ )
1082
1124
  if chunks is None:
1083
1125
  raise ValueError("chunks is required: the package never picks a chunk shape")
1084
1126
  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/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
@@ -77,6 +77,27 @@ def factor(
77
77
  return a[1] / b[1]
78
78
 
79
79
 
80
+ # The OME short form of each unit, by its full name; every spelling of a unit reaches its
81
+ # entry through the table above, so "nanometre", "nm" and "Nanometers" agree.
82
+ _SHORT = {
83
+ "picometer": "pm", "angstrom": "Å", "nanometer": "nm", "micrometer": "µm",
84
+ "millimeter": "mm", "centimeter": "cm", "meter": "m",
85
+ "nanosecond": "ns", "microsecond": "µs", "millisecond": "ms", "second": "s",
86
+ "minute": "min", "hour": "h", "day": "d",
87
+ } # fmt: skip
88
+ _SHORT_BY_SIZE = {_lookup(name): short for name, short in _SHORT.items()}
89
+
90
+
91
+ def abbreviation(unit: str) -> str:
92
+ """The OME short form of a unit: ``"nm"`` for any spelling of nanometre the table knows,
93
+ ``"µm"`` (U+00B5) for any spelling of micrometre, ``"Å"``, ``"s"``, ``"min"`` and so
94
+ on. A unit the table does not know (``""``, ``"pixel"``) is a ``ValueError``."""
95
+ found = _lookup(unit) if unit else None
96
+ if found is None or found not in _SHORT_BY_SIZE:
97
+ raise ValueError(f"unknown unit {unit!r}")
98
+ return _SHORT_BY_SIZE[found]
99
+
100
+
80
101
  # OME-NGFF's name for each unit the table knows, by (kind, size): every spelling of a unit
81
102
  # reaches its entry through the table above, so "nanometre", "nm" and "Nanometers" agree.
82
103
  # The American spelling is OME's.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,6 +156,11 @@ 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
 
@@ -202,6 +207,14 @@ wa.ome_multiscales(axes, units, levels, version="0.5", type=None) ; wa.level_pla
202
207
  wa.level_shape(shape, factors, origin=0) ; wa.chunk_key(index, format, ...) ; wa.parse_chunk_key(key, format, ...)
203
208
  wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="nanometer") # list an array you made yourself
204
209
  wa.units.ome_name("nm") -> "nanometer" ; wa.write.codecs_of(path) -> (codecs, shards)
210
+
211
+ # grids and blocks (0.3)
212
+ g = wa.Grid(shape, voxel_size, translation, axes=None, units="nanometer", chunks=None, shards=None) # or Grid.from_meta(meta, voxel_size=)
213
+ wa.create(path, grid=g, dtype="uint64")
214
+ for b in wa.blocks(shape, block_shape, context=0, box=None, clip=True): # also vol.blocks() and g.blocks()
215
+ b.begin, b.end, b.read_begin, b.read_end, b.inner, b.at_end, b.id # plain records; blocks[id] is O(1)
216
+ wa.level_factors(voxel_size, ratio=1.5, max_levels=None, stop_shape=None, shape=None)
217
+ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent(path) ; wa.store.basename(path)
205
218
  ```
206
219
 
207
220
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
@@ -0,0 +1,16 @@
1
+ worldarray/__init__.py,sha256=_g8gaopTTDUXz_vfEqaWzSEXSf3hflzNW6zjCLpiX-A,1828
2
+ worldarray/array.py,sha256=1lmdq-Mi5Nu4wWJ3d6Ek5ZeA0-QmJJ6JgHdsQG7ud3k,56051
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=nAls-m0vchrsua0q_1yx1K9-djlAnWtkgokCfXqbpIQ,10411
9
+ worldarray/retry.py,sha256=Uw4YP-tPwtg0tgpBzY0ux4F5vehdTZTp0385QH_vvrk,2766
10
+ worldarray/store.py,sha256=432iIX35ak_cHhibHcZd80ehH0MgN35rtxOtYU35LYg,19154
11
+ worldarray/units.py,sha256=02i7kpkPjB3HJ2-7ZAvEXLjv6j0mmfiYZjpUNwFDvz4,5238
12
+ worldarray/write.py,sha256=IjV2jtwSBBAcWKHfms9KPUALX5GDs7v-2FRpTMO2Gfo,33442
13
+ worldarray-0.3.0.dist-info/METADATA,sha256=lhY78UeT83ZZLyshx6u-DwSWFo-1aWqhXmaXYaKARXk,15280
14
+ worldarray-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ worldarray-0.3.0.dist-info/licenses/LICENSE,sha256=NsviVkqc3M7YUSeSNmisDSg3e3L8S4ofeIjthWm8pvk,1518
16
+ worldarray-0.3.0.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,,