worldarray 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (35) hide show
  1. worldarray-0.2.0/CHANGELOG.md +80 -0
  2. {worldarray-0.1.0 → worldarray-0.2.0}/PKG-INFO +35 -9
  3. {worldarray-0.1.0 → worldarray-0.2.0}/README.md +34 -8
  4. {worldarray-0.1.0 → worldarray-0.2.0}/docs/spec.md +485 -36
  5. {worldarray-0.1.0 → worldarray-0.2.0}/pyproject.toml +1 -1
  6. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/__init__.py +20 -3
  7. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/array.py +390 -55
  8. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/context.py +31 -0
  9. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/levels.py +12 -4
  10. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/placement.py +83 -24
  11. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/store.py +39 -4
  12. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/units.py +22 -0
  13. worldarray-0.2.0/src/worldarray/write.py +829 -0
  14. {worldarray-0.1.0 → worldarray-0.2.0}/tests/fixtures/metadata.json +8 -8
  15. worldarray-0.2.0/tests/fixtures/write.json +1690 -0
  16. {worldarray-0.1.0 → worldarray-0.2.0}/tests/test_access.py +16 -0
  17. {worldarray-0.1.0 → worldarray-0.2.0}/tests/test_metadata.py +33 -0
  18. {worldarray-0.1.0 → worldarray-0.2.0}/tests/test_read.py +49 -8
  19. worldarray-0.2.0/tests/test_write.py +754 -0
  20. {worldarray-0.1.0 → worldarray-0.2.0}/uv.lock +1 -1
  21. worldarray-0.1.0/CHANGELOG.md +0 -13
  22. {worldarray-0.1.0 → worldarray-0.2.0}/.github/workflows/ci.yml +0 -0
  23. {worldarray-0.1.0 → worldarray-0.2.0}/.github/workflows/docs.yml +0 -0
  24. {worldarray-0.1.0 → worldarray-0.2.0}/.github/workflows/publish.yml +0 -0
  25. {worldarray-0.1.0 → worldarray-0.2.0}/.gitignore +0 -0
  26. {worldarray-0.1.0 → worldarray-0.2.0}/LICENSE +0 -0
  27. {worldarray-0.1.0 → worldarray-0.2.0}/docs/changelog.md +0 -0
  28. {worldarray-0.1.0 → worldarray-0.2.0}/docs/index.md +0 -0
  29. {worldarray-0.1.0 → worldarray-0.2.0}/mkdocs.yml +0 -0
  30. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/geometry.py +0 -0
  31. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/resample.py +0 -0
  32. {worldarray-0.1.0 → worldarray-0.2.0}/src/worldarray/retry.py +0 -0
  33. {worldarray-0.1.0 → worldarray-0.2.0}/tests/conftest.py +0 -0
  34. {worldarray-0.1.0 → worldarray-0.2.0}/tests/test_geometry.py +0 -0
  35. {worldarray-0.1.0 → worldarray-0.2.0}/tests/test_resample.py +0 -0
@@ -0,0 +1,80 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (2026-10-22)
4
+
5
+ Metadata and placed creation, built to spec draft 3 (Behaviours 10 to 16). Nothing on the
6
+ read side changes.
7
+
8
+ - `ome_multiscales(axes, units, levels, version)` builds the complete OME-NGFF 0.4 or 0.5
9
+ group document with one explicit placement per level; `level_placement` and
10
+ `level_shape` derive a level from factors and an origin (Convention 1's rule inverted,
11
+ so a level built from a ROI origin is placed from it; `level_shape` absorbs float-noise
12
+ factors such as `0.3 / 0.1`). Units are written in OME's names (`"nm"` becomes
13
+ `"nanometer"`; `units.ome_name`), an unknown unit as given; the multiscale's `type` is
14
+ written when given. Expected documents are pinned in `tests/fixtures/write.json`, shared
15
+ with chunkmirage's TypeScript builder, each case with its resolved per-axis units and
16
+ types, fifteen cases.
17
+ - `create(path, shape, dtype, voxel_size, translation, chunks=..., ...)` writes the group
18
+ document (made when absent, else its one dataset entry added or replaced, every other
19
+ attribute kept) and then the array, as zarr v2 with OME 0.4 or zarr v3 with OME 0.5
20
+ following the group's format. `chunks` is required, codecs are explicit with a zstd
21
+ level 3 default, shards are a zarr v3 option. `exists` is `"error"` or `"adopt"` (an
22
+ identical array is opened for writing, a different one raises naming every difference,
23
+ a creation lost to another process is adopted after a short backoff). Nothing is ever
24
+ deleted, truncated or overwritten. Write units equal to the fill value are not stored
25
+ unless `store_fill=True`, and a stored unit written so loses its key, as zarr-python's
26
+ `write_empty_chunks=False` does. `create` also writes a bare group document for every
27
+ missing ancestor down from the `.zarr` root and follows the nearest existing ancestor's
28
+ zarr version, so a level made in a zarr v2 container is zarr v2 with OME 0.4 and the
29
+ hierarchy browses (cellmap-analyze, cellmap-flow). Adopting an identical array writes no
30
+ group document; after a write the document is read again and a lost entry is written
31
+ again, then raises (registration-station's concurrent block jobs).
32
+ - `ensure_group_entry` is public: list an array the package did not create, such as a
33
+ symlinked `s0` (mesh-n-bone), or a level filled before it is listed.
34
+ - `open(path, mode="r+")` opens an existing array for writing; a `WorldArray` pickles by
35
+ its arguments and its context's spec and reopens in the receiving process, a capped
36
+ context staying capped (mesh-n-bone's workers). `write.codecs_of` takes a path.
37
+ - `write_index(begin, end, data)` writes whole write units only (the shard when sharded,
38
+ else the chunk; the clipped last unit allowed), so writers in different processes need
39
+ no coordination; `partial=True` does tensorstore's read-modify-write. `write(box, data)`
40
+ maps a world box by the centre rule first. No cast, no padding, no resampling on write.
41
+ - `open_pyramid(path, mode="r+").add_level(name, factors=, origin=)` creates a level's
42
+ entry and empty array, placed and sized from the level below with its dtype, chunks,
43
+ shards, codecs and fill value copied; the downsampling itself stays with the caller.
44
+ - `chunk_key` and `parse_chunk_key` for zarr v2, zarr v3 (both encodings, sharded arrays
45
+ keyed by shard), N5 and precomputed, as free functions and on `WorldArray`;
46
+ `list_chunks` uses the same parser. `write_shape`, `ArrayMeta.shard_shape`,
47
+ `chunks_covering(write=True)`.
48
+ ## 0.1.1 (2026-10-22)
49
+
50
+ - funlib `voxel_size`/`offset` on the parent group place an array that carries none, for
51
+ zarr and N5 alike; funlib's keys on an N5 array are read in the array's C order, as
52
+ zarr-python's N5 store writes them (both from mesh-n-bone's review of its PR #48).
53
+ - A precomputed path that names a scale is that scale under `level=0` or `"first"` too;
54
+ other levels warn and are ignored, as on an array path.
55
+ - The GCE metadata guard is applied only off Google Cloud (decided from the DMI product
56
+ name and the `GCE_METADATA_*` variables), so metadata-server credentials keep working on
57
+ a Google VM.
58
+ - `Pyramid.placements()`, `voxel_sizes()` and `translations()` read the group document when
59
+ the pyramid was opened through one of its level paths too; they used to open every level
60
+ from there, and a missing level made them raise.
61
+ - **Placement change.** An N5 level with `downsamplingFactors` and no offset now sits with
62
+ its corner at the origin at every level, as Neuroglancer draws N5: translation
63
+ `f / 2 * base`. 0.1.0 followed BigDataViewer for `pixelResolution` pyramids, centring the
64
+ base's voxel 0 on the origin (`(f - 1) / 2 * base`, half a base voxel lower), while
65
+ placing `resolution` pyramids with the corner at the origin, so the two disagreed. Two
66
+ shared fixture cases move and are renamed: `n5_group_pixelresolution_and_downsampling_factors_corner_at_origin`
67
+ (translation 4, 4, 4) and `n5_bigstitcher_resolution_with_downsampling_factors_corner_at_origin`
68
+ (0.5, 0.157, 0.157).
69
+
70
+ ## 0.1.0 (2026-10-21)
71
+
72
+ - First version, built to `docs/spec.md`: open zarr v2, zarr v3, N5 and Neuroglancer
73
+ precomputed arrays and pyramids through tensorstore; one metadata precedence per format
74
+ with the voxel-centre rule for translations; index-space and world-box reads with
75
+ padding; level selection; nearest and linear resampling; retries; shared and capped
76
+ tensorstore contexts.
77
+ Built and trialled in one day against the readers it replaces in cellmap-flow,
78
+ cellmap-analyze, mesh-n-bone and chunkmirage, and checked on their own data by the
79
+ CineMap, liconn-err, registration-station and salamander repos; see `docs/spec.md`,
80
+ "Trial status". Hot-path reads cost 0.1 to 0.2 ms over a raw tensorstore read.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.1.0
3
+ Version: 0.2.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/
@@ -67,8 +67,7 @@ pip install worldarray
67
67
 
68
68
  ## Why
69
69
 
70
- Volumetric data at Janelia lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed
71
- stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
70
+ Volumetric data lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
72
71
  its voxel size, origin, units and axes, in its own dialect, and the dialects disagree on
73
72
  the one thing that matters most: whether a stored origin means the centre of the first
74
73
  voxel or its corner. Get that wrong and every object is half a voxel out of place, which
@@ -79,10 +78,12 @@ rule for each, picking a pyramid level for a wanted resolution, and reading a re
79
78
  given in physical units as a plain numpy block, padded where the region leaves the data,
80
79
  resampled when no level matches, fast enough for a model server or a thousand cluster
81
80
  workers, with retries for flaky object stores and thread caps for shared hosts. No one
82
- package did all of that, so every tool here wrote its own reader, and the readers drifted
83
- apart. worldarray is that reader, written once, with the rules stated and tested.
81
+ package did all of that, so every tool wrote its own reader, and the readers drifted apart. worldarray is that reader, written once, with the rules stated and tested.
84
82
 
85
- Its dependencies are `tensorstore` and `numpy`. It is read-only in this version. The
83
+ Its dependencies are `tensorstore` and `numpy`. Since 0.2 it also writes the one thing
84
+ every tool wrote for itself and got wrong in its own way: the OME-NGFF metadata of a placed
85
+ array or pyramid level, and the array itself, created once and filled by aligned writes
86
+ from any number of jobs. It never deletes, overwrites or downsamples. The
86
87
  [specification](https://janeliascicomp.github.io/worldarray/spec/) is the contract it is
87
88
  built to; every rule below is a test.
88
89
 
@@ -117,6 +118,13 @@ vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
117
118
 
118
119
  pyr = wa.open_pyramid(path) # every level, opened on first use
119
120
  pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
121
+
122
+ # writing: a placed level, its OME-NGFF group document written for it (zarr v3 + OME 0.5 here)
123
+ out = wa.create("out.zarr/s0", shape=(512, 512, 512), dtype="uint64",
124
+ voxel_size=(8, 8, 8), translation=(4, 4, 4), chunks=(64, 64, 64))
125
+ out.write_index((0, 0, 0), (64, 64, 64), block) # whole chunks only: many jobs, no locks
126
+ out.write(box, block) # or by world box
127
+ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # placed and sized; you fill it
120
128
  ```
121
129
 
122
130
  ## What it does
@@ -141,6 +149,13 @@ pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
141
149
  cancelled future on timeout.
142
150
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
143
151
  `snap` to a grid, and the voxel indices a world box selects.
152
+ - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
153
+ translation, axes and units, with each level's placement explicit or derived from
154
+ factors and an origin; `create` following the container's format and writing the group
155
+ documents an output needs down from its `.zarr` root, with explicit chunks and codecs,
156
+ and only two answers to "it exists": raise, or adopt an identical array after checking;
157
+ whole-unit writes from many processes with no coordination; `add_level` as metadata plus
158
+ an empty array; chunk keys for every format. Nothing is ever deleted or overwritten.
144
159
 
145
160
  ## The placement rule
146
161
 
@@ -154,7 +169,7 @@ moved half a voxel on reading:
154
169
  | OME-NGFF `translation` | centre | as is |
155
170
  | COSEM `transform.translate` | centre | as is |
156
171
  | `offset` beside `resolution` / `voxel_size` (`offset` missing: 0) | corner | `offset + voxel_size / 2` |
157
- | N5 `pixelResolution` or `resolution` with `downsamplingFactors f`, no offset | BigDataViewer | `(f - 1) / 2 * base` |
172
+ | N5 `pixelResolution` or `resolution` with `downsamplingFactors f`, no offset | corner at the origin at every level, as Neuroglancer draws N5 | `f / 2 * base` |
158
173
  | precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
159
174
  | nothing | a plain array | voxels of 1, corner at the origin |
160
175
 
@@ -178,13 +193,24 @@ wa.open_tensorstore(path, level=None, context=None)
178
193
  wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
179
194
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
180
195
  wa.Box ; wa.units.TABLE
196
+
197
+ # writing (0.2)
198
+ wa.create(path, shape, dtype, voxel_size, translation, *, axes=None, units="nanometer", chunks=..., shards=None,
199
+ fill_value=0, codecs="zstd", format=None, attrs=None, group_attrs=None, exists="error", store_fill=False) -> WorldArray
200
+ wa.open(path, mode="r+") ; wa.open_pyramid(path, mode="r+").add_level(name, factors=, origin=0, ...)
201
+ wa.ome_multiscales(axes, units, levels, version="0.5", type=None) ; wa.level_placement(voxel_size, translation, factors, origin=0)
202
+ wa.level_shape(shape, factors, origin=0) ; wa.chunk_key(index, format, ...) ; wa.parse_chunk_key(key, format, ...)
203
+ wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="nanometer") # list an array you made yourself
204
+ wa.units.ome_name("nm") -> "nanometer" ; wa.write.codecs_of(path) -> (codecs, shards)
181
205
  ```
182
206
 
183
207
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
184
- `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`;
208
+ `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`,
209
+ `writable`, `write_shape`;
185
210
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
186
211
  `read_index(begin, end, padding=, mask=, select=, transform=)`, `index_box(box)`,
187
- `chunks_covering(begin, end)`, `list_chunks()`, `reopen(context=)`.
212
+ `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
213
+ `write_index(begin, end, data, partial=)`, `write(box, data, partial=)`, `reopen(context=)`.
188
214
 
189
215
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
190
216
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -8,8 +8,7 @@ pip install worldarray
8
8
 
9
9
  ## Why
10
10
 
11
- Volumetric data at Janelia lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed
12
- stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
11
+ Volumetric data lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
13
12
  its voxel size, origin, units and axes, in its own dialect, and the dialects disagree on
14
13
  the one thing that matters most: whether a stored origin means the centre of the first
15
14
  voxel or its corner. Get that wrong and every object is half a voxel out of place, which
@@ -20,10 +19,12 @@ rule for each, picking a pyramid level for a wanted resolution, and reading a re
20
19
  given in physical units as a plain numpy block, padded where the region leaves the data,
21
20
  resampled when no level matches, fast enough for a model server or a thousand cluster
22
21
  workers, with retries for flaky object stores and thread caps for shared hosts. No one
23
- package did all of that, so every tool here wrote its own reader, and the readers drifted
24
- apart. worldarray is that reader, written once, with the rules stated and tested.
22
+ package did all of that, so every tool wrote its own reader, and the readers drifted apart. worldarray is that reader, written once, with the rules stated and tested.
25
23
 
26
- Its dependencies are `tensorstore` and `numpy`. It is read-only in this version. The
24
+ Its dependencies are `tensorstore` and `numpy`. Since 0.2 it also writes the one thing
25
+ every tool wrote for itself and got wrong in its own way: the OME-NGFF metadata of a placed
26
+ array or pyramid level, and the array itself, created once and filled by aligned writes
27
+ from any number of jobs. It never deletes, overwrites or downsamples. The
27
28
  [specification](https://janeliascicomp.github.io/worldarray/spec/) is the contract it is
28
29
  built to; every rule below is a test.
29
30
 
@@ -58,6 +59,13 @@ vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
58
59
 
59
60
  pyr = wa.open_pyramid(path) # every level, opened on first use
60
61
  pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
62
+
63
+ # writing: a placed level, its OME-NGFF group document written for it (zarr v3 + OME 0.5 here)
64
+ out = wa.create("out.zarr/s0", shape=(512, 512, 512), dtype="uint64",
65
+ voxel_size=(8, 8, 8), translation=(4, 4, 4), chunks=(64, 64, 64))
66
+ out.write_index((0, 0, 0), (64, 64, 64), block) # whole chunks only: many jobs, no locks
67
+ out.write(box, block) # or by world box
68
+ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # placed and sized; you fill it
61
69
  ```
62
70
 
63
71
  ## What it does
@@ -82,6 +90,13 @@ pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
82
90
  cancelled future on timeout.
83
91
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
84
92
  `snap` to a grid, and the voxel indices a world box selects.
93
+ - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
94
+ translation, axes and units, with each level's placement explicit or derived from
95
+ factors and an origin; `create` following the container's format and writing the group
96
+ documents an output needs down from its `.zarr` root, with explicit chunks and codecs,
97
+ and only two answers to "it exists": raise, or adopt an identical array after checking;
98
+ whole-unit writes from many processes with no coordination; `add_level` as metadata plus
99
+ an empty array; chunk keys for every format. Nothing is ever deleted or overwritten.
85
100
 
86
101
  ## The placement rule
87
102
 
@@ -95,7 +110,7 @@ moved half a voxel on reading:
95
110
  | OME-NGFF `translation` | centre | as is |
96
111
  | COSEM `transform.translate` | centre | as is |
97
112
  | `offset` beside `resolution` / `voxel_size` (`offset` missing: 0) | corner | `offset + voxel_size / 2` |
98
- | N5 `pixelResolution` or `resolution` with `downsamplingFactors f`, no offset | BigDataViewer | `(f - 1) / 2 * base` |
113
+ | N5 `pixelResolution` or `resolution` with `downsamplingFactors f`, no offset | corner at the origin at every level, as Neuroglancer draws N5 | `f / 2 * base` |
99
114
  | precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
100
115
  | nothing | a plain array | voxels of 1, corner at the origin |
101
116
 
@@ -119,13 +134,24 @@ wa.open_tensorstore(path, level=None, context=None)
119
134
  wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
120
135
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
121
136
  wa.Box ; wa.units.TABLE
137
+
138
+ # writing (0.2)
139
+ wa.create(path, shape, dtype, voxel_size, translation, *, axes=None, units="nanometer", chunks=..., shards=None,
140
+ fill_value=0, codecs="zstd", format=None, attrs=None, group_attrs=None, exists="error", store_fill=False) -> WorldArray
141
+ wa.open(path, mode="r+") ; wa.open_pyramid(path, mode="r+").add_level(name, factors=, origin=0, ...)
142
+ wa.ome_multiscales(axes, units, levels, version="0.5", type=None) ; wa.level_placement(voxel_size, translation, factors, origin=0)
143
+ wa.level_shape(shape, factors, origin=0) ; wa.chunk_key(index, format, ...) ; wa.parse_chunk_key(key, format, ...)
144
+ wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="nanometer") # list an array you made yourself
145
+ wa.units.ome_name("nm") -> "nanometer" ; wa.write.codecs_of(path) -> (codecs, shards)
122
146
  ```
123
147
 
124
148
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
125
- `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`;
149
+ `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`,
150
+ `writable`, `write_shape`;
126
151
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
127
152
  `read_index(begin, end, padding=, mask=, select=, transform=)`, `index_box(box)`,
128
- `chunks_covering(begin, end)`, `list_chunks()`, `reopen(context=)`.
153
+ `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
154
+ `write_index(begin, end, data, partial=)`, `write(box, data, partial=)`, `reopen(context=)`.
129
155
 
130
156
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
131
157
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also