worldarray 0.9.0__tar.gz → 0.10.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.
- {worldarray-0.9.0 → worldarray-0.10.0}/CHANGELOG.md +29 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/PKG-INFO +5 -2
- {worldarray-0.9.0 → worldarray-0.10.0}/README.md +4 -1
- {worldarray-0.9.0 → worldarray-0.10.0}/docs/spec.md +55 -11
- {worldarray-0.9.0 → worldarray-0.10.0}/pyproject.toml +1 -1
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/__init__.py +1 -1
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/array.py +113 -20
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/context.py +25 -6
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/placement.py +79 -5
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_access.py +29 -0
- worldarray-0.10.0/tests/test_axes_order.py +197 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/uv.lock +1 -1
- {worldarray-0.9.0 → worldarray-0.10.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/.gitignore +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/LICENSE +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/docs/changelog.md +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/docs/index.md +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/mkdocs.yml +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/retry.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/store.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/units.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/src/worldarray/write.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/conftest.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_geometry.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_grid.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_metadata.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_read.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_resample.py +0 -0
- {worldarray-0.9.0 → worldarray-0.10.0}/tests/test_write.py +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
Axes in another order, on request. No placement change, no unit table change, and no
|
|
6
|
+
default changes: without `axes_order`, every array opens as it did in 0.9.
|
|
7
|
+
|
|
8
|
+
- `open`, `open_pyramid` and `metadata` take `axes_order`: `"stored"` (the default, for
|
|
9
|
+
good), `"zyx"` (the axes named z, y and x last, in that order, the others first in
|
|
10
|
+
stored order) or a sequence naming each axis once. An OME-Zarr written x, y, z
|
|
11
|
+
(TensorSwitch's) then reads as z, y, x, and a channel-last array channel first. The
|
|
12
|
+
names move with the data, and shape, chunks, shards, units, voxel size and
|
|
13
|
+
translation with them, in `.ts`, `meta`, `grid`, reads, writes, `blocks`, a pyramid's
|
|
14
|
+
placements and `level_meta`, level choice by voxel size, `base_voxel_size`, `reopen`
|
|
15
|
+
and a pickle. `open_tensorstore` stays in the store's order.
|
|
16
|
+
- On such a handle, `chunk_key`, `parse_chunk_key` and `list_chunks` take and give
|
|
17
|
+
indices in the presented order (a selected axis in its stored place); the keys are
|
|
18
|
+
the store's. `add_level` takes its per-axis arguments in the pyramid's order and
|
|
19
|
+
creates the array in the group's.
|
|
20
|
+
|
|
21
|
+
## 0.9.1 (2026-10-26)
|
|
22
|
+
|
|
23
|
+
A fix for exit. No placement change, no unit table change, and no default changes.
|
|
24
|
+
|
|
25
|
+
- The watchdog thread stops at exit, before the interpreter finalizes. On Python up to
|
|
26
|
+
3.13 a process could abort once all its work was done ("terminate called without an
|
|
27
|
+
active exception", exit 134) when finalization began while the thread was inside a
|
|
28
|
+
tensorstore call; an LSF job that did this looked failed. Now an `atexit` handler,
|
|
29
|
+
which runs once the program's other threads are joined, ends the thread and waits
|
|
30
|
+
for it, and no new one starts after that.
|
|
31
|
+
|
|
3
32
|
## 0.9.0 (2026-10-23)
|
|
4
33
|
|
|
5
34
|
A job script can size each worker's threads. No placement change, no unit table change, and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: worldarray
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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/
|
|
@@ -116,6 +116,7 @@ vol = wa.open(path, units=None)
|
|
|
116
116
|
vol.meta # shape, dtype, chunk_shape, axes, units, voxel_size, translation, attrs, ...
|
|
117
117
|
vol.ts # the tensorstore itself, C order, origin 0
|
|
118
118
|
vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
|
|
119
|
+
wa.open(path, axes_order="zyx") # an x, y, z store (TensorSwitch's) presented z, y, x, names and all
|
|
119
120
|
|
|
120
121
|
pyr = wa.open_pyramid(path) # every level, opened on first use
|
|
121
122
|
pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
|
|
@@ -192,7 +193,9 @@ Dialects that store the corner are moved half a voxel on reading:
|
|
|
192
193
|
| precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
|
|
193
194
|
| nothing | a plain array | voxels of 1, corner at the origin |
|
|
194
195
|
|
|
195
|
-
Axes are the store's C order; N5 and precomputed are transposed to it.
|
|
196
|
+
Axes are the store's C order; N5 and precomputed are transposed to it. `axes_order="zyx"`
|
|
197
|
+
presents the axes named z, y and x last, in that order, for a reader that indexes axes by
|
|
198
|
+
position; the store's order stays the default. Unlisted or
|
|
196
199
|
conflicting attributes warn; a voxel size that disagrees with the OME scale raises, since
|
|
197
200
|
that is how stale metadata is caught. Metadata the parser did not consume stays available
|
|
198
201
|
raw. The full precedence per format, the half-voxel rules, and every read behaviour are
|
|
@@ -57,6 +57,7 @@ vol = wa.open(path, units=None)
|
|
|
57
57
|
vol.meta # shape, dtype, chunk_shape, axes, units, voxel_size, translation, attrs, ...
|
|
58
58
|
vol.ts # the tensorstore itself, C order, origin 0
|
|
59
59
|
vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
|
|
60
|
+
wa.open(path, axes_order="zyx") # an x, y, z store (TensorSwitch's) presented z, y, x, names and all
|
|
60
61
|
|
|
61
62
|
pyr = wa.open_pyramid(path) # every level, opened on first use
|
|
62
63
|
pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
|
|
@@ -133,7 +134,9 @@ Dialects that store the corner are moved half a voxel on reading:
|
|
|
133
134
|
| precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
|
|
134
135
|
| nothing | a plain array | voxels of 1, corner at the origin |
|
|
135
136
|
|
|
136
|
-
Axes are the store's C order; N5 and precomputed are transposed to it.
|
|
137
|
+
Axes are the store's C order; N5 and precomputed are transposed to it. `axes_order="zyx"`
|
|
138
|
+
presents the axes named z, y and x last, in that order, for a reader that indexes axes by
|
|
139
|
+
position; the store's order stays the default. Unlisted or
|
|
137
140
|
conflicting attributes warn; a voxel size that disagrees with the OME scale raises, since
|
|
138
141
|
that is how stale metadata is caught. Metadata the parser did not consume stays available
|
|
139
142
|
raw. The full precedence per format, the half-voxel rules, and every read behaviour are
|
|
@@ -131,6 +131,9 @@ vol = wa.open(
|
|
|
131
131
|
# part of a box outside the data
|
|
132
132
|
method="nearest", # "nearest" | "linear": the resampling kernel; no dtype heuristic (Behaviour 5)
|
|
133
133
|
select=None, # {"c": 0, "t": 0}: pin non-spatial axes to one index; else they are kept whole
|
|
134
|
+
axes_order="stored", # "stored": the store's order (Convention 2) | "zyx": the axes named z, y and x last, in
|
|
135
|
+
# that order, the others first in stored order | a sequence naming each axis left after
|
|
136
|
+
# `select` once (0.10); every per-axis field and the data move with their names
|
|
134
137
|
units="nanometer", # what world coordinates are in on spatial axes; a dict per axis kind
|
|
135
138
|
# ({"space": "micrometer", "time": "second"}); None keeps the store's own units
|
|
136
139
|
context=None, # a tensorstore.Context; default shared_context(0)
|
|
@@ -144,7 +147,7 @@ vol = wa.open(
|
|
|
144
147
|
strict_units=True, # False: an unknown unit ("pixel") is taken as the target unit, with a warning
|
|
145
148
|
)
|
|
146
149
|
|
|
147
|
-
vol.ts # tensorstore.TensorStore:
|
|
150
|
+
vol.ts # tensorstore.TensorStore: the axes in `axes_order`, origin 0, selected axes dropped
|
|
148
151
|
vol.meta # ArrayMeta in the store's own units
|
|
149
152
|
vol.grid # ArrayMeta in the requested units, every axis
|
|
150
153
|
vol.shape, vol.dtype, vol.axes, vol.spatial, vol.level_name, vol.levels, vol.remote
|
|
@@ -168,7 +171,7 @@ vol.index_box(box) -> (begin, end) # the voxels a world box selects on this gri
|
|
|
168
171
|
vol.chunks_covering(begin, end) # chunk indices a voxel box touches
|
|
169
172
|
vol.reopen(context=None, **overrides) -> WorldArray # the same array on another context (a worker changing its concurrency)
|
|
170
173
|
|
|
171
|
-
pyr = wa.open_pyramid(path, context=None, units="nanometer", select=None)
|
|
174
|
+
pyr = wa.open_pyramid(path, context=None, units="nanometer", select=None, axes_order="stored")
|
|
172
175
|
pyr.names; pyr.levels; pyr.by_name("s1"); pyr.select(voxel_size, mode="floor")
|
|
173
176
|
pyr.metas() ; pyr.level_meta("s1") # ArrayMeta per level from the documents alone: shape, dtype, chunks (a sharded
|
|
174
177
|
# array's inner chunk), fill and placement, with no tensorstore opened
|
|
@@ -177,11 +180,13 @@ pyr.voxel_sizes() ; pyr.translations() # where it lists the levels (OME-NGFF
|
|
|
177
180
|
# choosing or placing a level opens nothing, whether the pyramid was opened by its
|
|
178
181
|
# group path or by one of its level paths; else by opening each level
|
|
179
182
|
|
|
180
|
-
wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
183
|
+
wa.metadata(path, level=None, units=None, open=True, retries=None, axes_order="stored") -> ArrayMeta
|
|
184
|
+
# open=False: from the documents alone
|
|
181
185
|
wa.detect(path, missing_ok=False, retries=None) -> Node | None # format, kind (array|group), path, docs, scale_key; .attrs (raw user
|
|
182
186
|
# attributes), .multiscales (OME 0.5's `ome` unwrapped)
|
|
183
187
|
wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore.TensorStore # reads no world metadata, so an
|
|
184
|
-
# array with odd placement attributes (a coordinate array) still opens
|
|
188
|
+
# array with odd placement attributes (a coordinate array) still opens;
|
|
189
|
+
# always the store's order (Convention 2)
|
|
185
190
|
wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
|
|
186
191
|
wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
|
|
187
192
|
wa.select_level(levels, voxel_size, mode) ; wa.Box ; wa.Retry(max_attempts=10, timeout=None, base_delay=1.0)
|
|
@@ -196,7 +201,7 @@ wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present und
|
|
|
196
201
|
# metadata document needed, so a directory of chunk files with no .zarray lists too
|
|
197
202
|
```
|
|
198
203
|
|
|
199
|
-
`ArrayMeta` is a frozen dataclass with one entry per axis, in the array's
|
|
204
|
+
`ArrayMeta` is a frozen dataclass with one entry per axis, in the array's order (Convention 2), and
|
|
200
205
|
deliberately the same field names as chunkmirage's `ArrayInfo`: `shape`, `dtype`,
|
|
201
206
|
`chunk_shape` (tensorstore's read chunk), `axes` (as stored; `("t","c","z","y","x")[-ndim:]`
|
|
202
207
|
when the store names none), `axes_named`, `units` (as stored, any string; `""` when none),
|
|
@@ -276,7 +281,8 @@ wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="na
|
|
|
276
281
|
# document and missing ancestors created, nothing written when the entry already says the same
|
|
277
282
|
# (Behaviour 12). `create` and `add_level` call this
|
|
278
283
|
wa.write.codecs_of(path_or_node) -> (codecs, shards) # an array's codecs in its format's dialect and its shard shape, for
|
|
279
|
-
# a mirror created like it (cellmap-flow's sync)
|
|
284
|
+
# a mirror created like it (cellmap-flow's sync); the shards in the store's order, so a
|
|
285
|
+
# mirror of a handle opened with `axes_order` takes vol.meta.shard_shape (Grid.from_meta does)
|
|
280
286
|
|
|
281
287
|
vol = wa.create(
|
|
282
288
|
path, # ".../vol.zarr/s0": the last component is the level, its parent the multiscale group, made if absent
|
|
@@ -422,9 +428,21 @@ this document and that one differ, fix one of them.
|
|
|
422
428
|
|
|
423
429
|
2. **Axis order is the store's C order; N5 and precomputed are transposed to it** on read,
|
|
424
430
|
so `(z, y, x)` for a volume, `(c, z, y, x)` with channels, `(t, c, z, y, x)` for OME
|
|
425
|
-
0.5. Nothing is reordered beyond that transposition
|
|
431
|
+
0.5. Nothing is reordered beyond that transposition unless asked. `axes_order="zyx"`
|
|
432
|
+
(0.10, on `open`, `open_pyramid` and `metadata`) presents the axes named z, y and x last,
|
|
433
|
+
in that order, and the others first in their stored order, so an OME-Zarr written x, y, z
|
|
434
|
+
(TensorSwitch's) reads as z, y, x and a channel-last array channel first; an array naming
|
|
435
|
+
none of z, y and x is unchanged. A sequence of names presents exactly that order and must
|
|
436
|
+
name each axis left after `select` once. The names move with the data, and shape, chunks,
|
|
437
|
+
shards, units, voxel size and translation with them, in `.ts`, `meta`, `grid`, every read
|
|
438
|
+
and write, the pyramid's placements and `level_meta` (where a selected axis, still there,
|
|
439
|
+
keeps its stored place) and a pickle; `open_tensorstore` stays in the store's order. The
|
|
440
|
+
default is the store's order for good, not only until 1.0: an x, y, z store read
|
|
441
|
+
faithfully is x, y, z with its names, which a reader placing axes by name needs
|
|
442
|
+
(Neuroglancer, chunkmirage, a mirror), and a reader indexing axes by position asks for its
|
|
443
|
+
order or checks the names (David, 2026-11-03). `select` pins non-spatial axes. A
|
|
426
444
|
non-zero stored origin (precomputed `voxel_offset`) is moved to 0 in `.ts` and carried in
|
|
427
|
-
`translation`.
|
|
445
|
+
`translation`. Sharded stores (zarr v3 `sharding_indexed`, sharded precomputed) read like any
|
|
428
446
|
other through tensorstore; `chunk_shape` is the inner read chunk, not the shard. Unnamed axes are called `("t", "c", "z", "y", "x")[-ndim:]` (chunkmirage's
|
|
429
447
|
default) with `axes_named` False, so a consumer can apply its own default instead.
|
|
430
448
|
|
|
@@ -692,7 +710,15 @@ this document and that one differ, fix one of them.
|
|
|
692
710
|
copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
|
|
693
711
|
of CPU). A stranded task then runs within about a second whoever waits on it: the
|
|
694
712
|
package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
|
|
695
|
-
net.
|
|
713
|
+
net. The thread stops at exit (0.9.1): an `atexit` handler, which Python runs once the
|
|
714
|
+
program's other threads are joined and before it finalizes, ends it and waits for it
|
|
715
|
+
(at most the longest one kick takes), and no new one starts after that. Python up to
|
|
716
|
+
3.13 ends a daemon thread that takes the GIL back during finalization with
|
|
717
|
+
`pthread_exit`, and inside a tensorstore call that aborts the process with "terminate
|
|
718
|
+
called without an active exception" and exit 134 after all the work is done (once in
|
|
719
|
+
six of mesh-n-bone's Linux CI runs on Python 3.12, 2026-10-26; every time when a
|
|
720
|
+
process exits while the thread kicks flat out). Open question 14 says when the
|
|
721
|
+
watchdog goes.
|
|
696
722
|
|
|
697
723
|
7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
|
|
698
724
|
tensorstore's cache is off and the levels of one pyramid share one context. Its
|
|
@@ -902,6 +928,10 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
|
|
|
902
928
|
name is replaced; the group's other content is untouched (Behaviour 12). It writes no
|
|
903
929
|
voxel data: downsampling is the caller's, per block, through `write_index`, labels by
|
|
904
930
|
mode and images by mean as each repo decides. `exists` means what it means for `create`.
|
|
931
|
+
On a pyramid opened with `axes_order` (0.10), the per-axis arguments are in the order it
|
|
932
|
+
presents (`factors`, `origin`, `voxel_size` and `translation` on its spatial axes, `shape`,
|
|
933
|
+
`chunks` and `shards` as `level_meta` lists them), the array is created in the group's own
|
|
934
|
+
order, and the level comes back in the pyramid's.
|
|
905
935
|
|
|
906
936
|
15. **Chunk keys.** `chunk_key(index)` is the key, relative to the array, of the write unit
|
|
907
937
|
at `index`: zarr v2 indices joined by the array's `dimension_separator` (`0.1.2`); zarr
|
|
@@ -913,7 +943,11 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
|
|
|
913
943
|
(`zarr.json`, `.zarray`, `attributes.json`, `info`, a `c/` key of the wrong arity).
|
|
914
944
|
`list_chunk_keys` and `list_chunks` use the same parser. The free functions take the
|
|
915
945
|
format and the array's facts instead of an open array, for an array served from nowhere
|
|
916
|
-
(chunkmirage's frontends) or about to be created.
|
|
946
|
+
(chunkmirage's frontends) or about to be created. On a handle opened with `axes_order`
|
|
947
|
+
(0.10), `chunk_key`, `parse_chunk_key` and `list_chunks` take and give indices in the
|
|
948
|
+
order the handle presents, a selected axis in its stored place, as an N5 index is in C
|
|
949
|
+
order and its key x first; the key is the store's. The free functions stay in the
|
|
950
|
+
store's order.
|
|
917
951
|
|
|
918
952
|
16. **Write units.** `write_shape` is the shard shape of a sharded zarr v3 array, else the
|
|
919
953
|
chunk shape; `ArrayMeta.shard_shape` is None when unsharded; `chunk_shape` stays the
|
|
@@ -1089,7 +1123,17 @@ through a pickle, and a create whose first attempt lands after its timeout adopt
|
|
|
1089
1123
|
array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
|
|
1090
1124
|
blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
|
|
1091
1125
|
ignored with a thread count, and carried through a pickle; a handle pickled on the driver
|
|
1092
|
-
sized in a spawned worker by that worker's `WORLDARRAY_THREADS`.
|
|
1126
|
+
sized in a spawned worker by that worker's `WORLDARRAY_THREADS`. 0.9.1: a process whose
|
|
1127
|
+
finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly. 0.10:
|
|
1128
|
+
an x, y, z array and a c, x, y, z one opened with `axes_order="zyx"` reading as the
|
|
1129
|
+
transpose of the stored data with every per-axis field moved, a write through the
|
|
1130
|
+
reordered view landing in the stored array's order, its chunk keys unchanged and its
|
|
1131
|
+
indices presented; a sequence order, and a bad order refused; an N5 array and an
|
|
1132
|
+
unnamed one unchanged under "zyx"; a selected channel keeping its place in a key index;
|
|
1133
|
+
`metadata` with and without opening; a pyramid's placements and `level_meta` reordered, a
|
|
1134
|
+
level chosen by an anisotropic voxel size, `base_voxel_size` applied to the right axes, and
|
|
1135
|
+
`add_level` taking its factors in the presented order; the order carried through
|
|
1136
|
+
`reopen` and a pickle.
|
|
1093
1137
|
Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
|
|
1094
1138
|
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
|
|
1095
1139
|
worker exits idle, so a plain context strands some for good (13 of about 7,000 on
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "worldarray"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -29,11 +29,16 @@ from .geometry import Box, index_range, is_aligned
|
|
|
29
29
|
from .levels import LevelRef, Mode, discover, discover_group, same_voxel_size, select_level
|
|
30
30
|
from .placement import (
|
|
31
31
|
ArrayMeta,
|
|
32
|
+
AxesOrder,
|
|
32
33
|
_dims_from_docs,
|
|
34
|
+
axes_permutation,
|
|
35
|
+
check_axes_order,
|
|
33
36
|
default_axes,
|
|
34
37
|
light_levels,
|
|
35
38
|
read_meta,
|
|
36
39
|
read_meta_light,
|
|
40
|
+
spatial_axes,
|
|
41
|
+
spatial_order,
|
|
37
42
|
)
|
|
38
43
|
from .retry import Retry, read_with_retries, write_with_retries
|
|
39
44
|
from .store import (
|
|
@@ -92,6 +97,7 @@ class WorldArray:
|
|
|
92
97
|
strict_units: bool = True,
|
|
93
98
|
mode: str = "r",
|
|
94
99
|
store_fill: bool = False,
|
|
100
|
+
axes_order: AxesOrder = "stored",
|
|
95
101
|
_ref: LevelRef | None = None,
|
|
96
102
|
_node: Node | None = None,
|
|
97
103
|
):
|
|
@@ -112,6 +118,7 @@ class WorldArray:
|
|
|
112
118
|
self.context = context if context is not None else shared_context(0)
|
|
113
119
|
self.retries = retries if retries is not None else Retry()
|
|
114
120
|
self._select = dict(select or {})
|
|
121
|
+
self._axes_order = check_axes_order(axes_order)
|
|
115
122
|
self._opts = dict(
|
|
116
123
|
level=level,
|
|
117
124
|
voxel_size=voxel_size,
|
|
@@ -124,6 +131,7 @@ class WorldArray:
|
|
|
124
131
|
strict_units=strict_units,
|
|
125
132
|
mode=mode,
|
|
126
133
|
store_fill=store_fill,
|
|
134
|
+
axes_order=self._axes_order,
|
|
127
135
|
)
|
|
128
136
|
self._anchor = tuple(float(a) for a in anchor) if anchor is not None else None
|
|
129
137
|
|
|
@@ -147,7 +155,21 @@ class WorldArray:
|
|
|
147
155
|
self._meta = read_meta(
|
|
148
156
|
node_for_level, self._store, scale_index=ref.scale_index, conflicts=metadata_conflicts
|
|
149
157
|
)
|
|
158
|
+
stored_axes = self._meta.axes
|
|
150
159
|
self._apply_select()
|
|
160
|
+
# the order the axes are presented in (Conventions 2), and the stored axis at each
|
|
161
|
+
# place of a chunk key's index: a selected axis keeps its stored place there
|
|
162
|
+
order = axes_permutation(self._meta.axes, self._axes_order)
|
|
163
|
+
if order != tuple(range(len(order))):
|
|
164
|
+
self._store = self._store[ts.d[order].transpose[:]]
|
|
165
|
+
self._meta = self._meta.permuted(order)
|
|
166
|
+
if node_for_level.format == "precomputed": # a precomputed key names the spatial chunk
|
|
167
|
+
sp = spatial_axes(stored_axes)
|
|
168
|
+
self._key_order = tuple(
|
|
169
|
+
sp.index(i) for i in spatial_order(stored_axes, self._meta.axes)
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
self._key_order = axes_permutation(stored_axes, self._meta.axes, fixed=self._select)
|
|
151
173
|
if base_voxel_size is not None:
|
|
152
174
|
self._meta = self._rescaled_to_base(base_voxel_size, base_units)
|
|
153
175
|
space, time = _unit_targets(units)
|
|
@@ -198,7 +220,12 @@ class WorldArray:
|
|
|
198
220
|
warnings.warn(f"{self.path} is an array; level={level!r} is ignored", stacklevel=3)
|
|
199
221
|
return LevelRef(node.path.rsplit("/", 1)[-1], node.path) # the array itself, always
|
|
200
222
|
self._pyramid = Pyramid(
|
|
201
|
-
self.path,
|
|
223
|
+
self.path,
|
|
224
|
+
context=self.context,
|
|
225
|
+
units=self.units,
|
|
226
|
+
select=self._select,
|
|
227
|
+
axes_order=self._axes_order,
|
|
228
|
+
_node=node,
|
|
202
229
|
)
|
|
203
230
|
if level is not None:
|
|
204
231
|
return self._pyramid._ref(level)
|
|
@@ -227,7 +254,13 @@ class WorldArray:
|
|
|
227
254
|
by the same per-axis factor, so stores whose metadata is in voxels of level 0 (as
|
|
228
255
|
BigStitcher writes) or unitless (a BigDataViewer N5) can be placed correctly."""
|
|
229
256
|
base = tuple(float(v) for v in base)
|
|
230
|
-
native = Pyramid(
|
|
257
|
+
native = Pyramid(
|
|
258
|
+
self.path,
|
|
259
|
+
context=self.context,
|
|
260
|
+
units=None,
|
|
261
|
+
select=self._select,
|
|
262
|
+
axes_order=self._axes_order,
|
|
263
|
+
)
|
|
231
264
|
level0 = native.levels[0].meta if len(native) > 1 else self._meta
|
|
232
265
|
spatial = self._meta.spatial
|
|
233
266
|
if len(base) != len(spatial):
|
|
@@ -394,7 +427,11 @@ class WorldArray:
|
|
|
394
427
|
def levels_pyramid(self) -> Pyramid:
|
|
395
428
|
if self._pyramid is None:
|
|
396
429
|
self._pyramid = Pyramid(
|
|
397
|
-
self._where,
|
|
430
|
+
self._where,
|
|
431
|
+
context=self.context,
|
|
432
|
+
units=self.units,
|
|
433
|
+
select=self._select,
|
|
434
|
+
axes_order=self._axes_order,
|
|
398
435
|
)
|
|
399
436
|
return self._pyramid
|
|
400
437
|
|
|
@@ -507,19 +544,37 @@ class WorldArray:
|
|
|
507
544
|
params["origin"] = tuple(int(o) for o in scale.get("voxel_offset", [0, 0, 0]))[::-1]
|
|
508
545
|
return params
|
|
509
546
|
|
|
547
|
+
def _stored_index(self, index: Sequence[int]) -> tuple[int, ...]:
|
|
548
|
+
"""A key index in the presented order, in the store's."""
|
|
549
|
+
index, order = tuple(index), self._key_order
|
|
550
|
+
if len(index) != len(order):
|
|
551
|
+
return index # the key function says what is wrong
|
|
552
|
+
stored = [0] * len(order)
|
|
553
|
+
for place, axis in enumerate(order):
|
|
554
|
+
stored[axis] = index[place]
|
|
555
|
+
return tuple(stored)
|
|
556
|
+
|
|
557
|
+
def _presented_index(self, index: tuple[int, ...] | None) -> tuple[int, ...] | None:
|
|
558
|
+
"""A key index in the store's order, in the presented one."""
|
|
559
|
+
if index is None or len(index) != len(self._key_order):
|
|
560
|
+
return index
|
|
561
|
+
return tuple(index[axis] for axis in self._key_order)
|
|
562
|
+
|
|
510
563
|
def chunk_key(self, index: Sequence[int]) -> str:
|
|
511
564
|
"""The storage key, relative to the array, of the write unit at ``index`` on the
|
|
512
|
-
storage grid (Behaviour 15). A precomputed
|
|
513
|
-
|
|
565
|
+
storage grid, in the order the axes are presented (Behaviour 15). A precomputed
|
|
566
|
+
array's key names the spatial chunk only."""
|
|
567
|
+
return _write.chunk_key(self._stored_index(index), **self._key_params())
|
|
514
568
|
|
|
515
569
|
def parse_chunk_key(self, key: str) -> tuple[int, ...] | None:
|
|
516
570
|
"""``chunk_key`` inverted; None for a key that is not a chunk."""
|
|
517
|
-
return _write.parse_chunk_key(key, **self._key_params())
|
|
571
|
+
return self._presented_index(_write.parse_chunk_key(key, **self._key_params()))
|
|
518
572
|
|
|
519
573
|
def list_chunks(self) -> list[tuple[int, ...]]:
|
|
520
|
-
"""The chunk indices that exist in storage (
|
|
521
|
-
array's keys with ``parse_chunk_key``: zarr v2 and v3
|
|
522
|
-
shards), N5, and unsharded precomputed. Works on any
|
|
574
|
+
"""The chunk indices that exist in storage (the storage grid, in the order the axes
|
|
575
|
+
are presented), by listing the array's keys with ``parse_chunk_key``: zarr v2 and v3
|
|
576
|
+
(a sharded array lists its shards), N5, and unsharded precomputed. Works on any
|
|
577
|
+
kvstore, URLs included."""
|
|
523
578
|
params = self._key_params()
|
|
524
579
|
kv = ts.KvStore.open(self._store.kvstore.spec()).result()
|
|
525
580
|
keys = [k.decode() if isinstance(k, bytes) else str(k) for k in kv.list().result()]
|
|
@@ -529,7 +584,7 @@ class WorldArray:
|
|
|
529
584
|
k.rsplit("/", 1)[-1] if params["format"] == "precomputed" else k, **params
|
|
530
585
|
)
|
|
531
586
|
if idx is not None:
|
|
532
|
-
found.add(idx)
|
|
587
|
+
found.add(self._presented_index(idx))
|
|
533
588
|
return sorted(found)
|
|
534
589
|
|
|
535
590
|
# --- writing (Behaviour 13) ----------------------------------------------------
|
|
@@ -1013,6 +1068,7 @@ class Pyramid:
|
|
|
1013
1068
|
units: Units = "nanometer",
|
|
1014
1069
|
select: dict[str, int] | None = None,
|
|
1015
1070
|
mode: str = "r",
|
|
1071
|
+
axes_order: AxesOrder = "stored",
|
|
1016
1072
|
_node: Node | None = None,
|
|
1017
1073
|
**kwargs,
|
|
1018
1074
|
):
|
|
@@ -1023,7 +1079,8 @@ class Pyramid:
|
|
|
1023
1079
|
self.units = units
|
|
1024
1080
|
self._select = select or {}
|
|
1025
1081
|
self._mode = mode
|
|
1026
|
-
self.
|
|
1082
|
+
self._axes_order = check_axes_order(axes_order)
|
|
1083
|
+
self._kwargs = {**kwargs, "mode": mode, "axes_order": self._axes_order}
|
|
1027
1084
|
self._node = _node if _node is not None else detect(path)
|
|
1028
1085
|
self._group, self._refs = discover_group(self._node) # the node listing the levels
|
|
1029
1086
|
if not self._refs:
|
|
@@ -1079,7 +1136,11 @@ class Pyramid:
|
|
|
1079
1136
|
space, _ = _unit_targets(self.units)
|
|
1080
1137
|
# the group the levels were discovered from, whichever path opened the pyramid
|
|
1081
1138
|
group = self._group
|
|
1082
|
-
light =
|
|
1139
|
+
light = (
|
|
1140
|
+
light_levels(group, self.names, space, self._axes_order)
|
|
1141
|
+
if group is not None
|
|
1142
|
+
else None
|
|
1143
|
+
)
|
|
1083
1144
|
if light is None:
|
|
1084
1145
|
light = [
|
|
1085
1146
|
(lvl.level_name, lvl.source_voxel_size, lvl.translation) for lvl in self.levels
|
|
@@ -1090,12 +1151,15 @@ class Pyramid:
|
|
|
1090
1151
|
def level_meta(self, level: str | int) -> ArrayMeta:
|
|
1091
1152
|
"""One level's ``ArrayMeta`` in native units from its documents alone, with no
|
|
1092
1153
|
tensorstore opened (shape, dtype, chunks and placement; a sharded array's inner
|
|
1093
|
-
chunk)
|
|
1154
|
+
chunk), every axis in the order the pyramid presents them, a selected one in its
|
|
1155
|
+
stored place."""
|
|
1094
1156
|
ref = self._ref(level)
|
|
1095
1157
|
conflicts = self._kwargs.get("metadata_conflicts", "error")
|
|
1096
1158
|
if ref.scale_index is not None:
|
|
1097
|
-
|
|
1098
|
-
|
|
1159
|
+
meta = read_meta_light(self._node, scale_index=ref.scale_index, conflicts=conflicts)
|
|
1160
|
+
else:
|
|
1161
|
+
meta = read_meta_light(detect(ref.path), conflicts=conflicts)
|
|
1162
|
+
return meta.permuted(axes_permutation(meta.axes, self._axes_order, fixed=self._select))
|
|
1099
1163
|
|
|
1100
1164
|
def metas(self) -> list[ArrayMeta]:
|
|
1101
1165
|
"""``level_meta`` for every level, in order."""
|
|
@@ -1141,6 +1205,12 @@ class Pyramid:
|
|
|
1141
1205
|
base_node = detect(base_ref.path)
|
|
1142
1206
|
base = read_meta_light(base_node, conflicts=self._kwargs.get("metadata_conflicts", "error"))
|
|
1143
1207
|
ndim, spatial = base.ndim, base.spatial
|
|
1208
|
+
# the arguments are in the order the pyramid presents the axes; the array is the group's
|
|
1209
|
+
order = axes_permutation(base.axes, self._axes_order, fixed=self._select)
|
|
1210
|
+
factors, origin, voxel_size, translation, shape, chunks, shards = (
|
|
1211
|
+
_in_stored_order(v, order, spatial)
|
|
1212
|
+
for v in (factors, origin, voxel_size, translation, shape, chunks, shards)
|
|
1213
|
+
)
|
|
1144
1214
|
if voxel_size is None or translation is None:
|
|
1145
1215
|
if factors is None:
|
|
1146
1216
|
raise ValueError("pass factors (and origin), or voxel_size and translation")
|
|
@@ -1184,7 +1254,7 @@ class Pyramid:
|
|
|
1184
1254
|
)
|
|
1185
1255
|
self._group, self._refs = discover_group(detect(group.path))
|
|
1186
1256
|
self._light, self._levels = None, {}
|
|
1187
|
-
return vol
|
|
1257
|
+
return vol if self._axes_order == "stored" else vol.reopen(axes_order=self._axes_order)
|
|
1188
1258
|
|
|
1189
1259
|
def _select_ref(self, voxel_size, mode: Mode = "floor") -> LevelRef:
|
|
1190
1260
|
name, _ = select_level(self.voxel_sizes(), voxel_size, mode)
|
|
@@ -1337,6 +1407,27 @@ def create(
|
|
|
1337
1407
|
)
|
|
1338
1408
|
|
|
1339
1409
|
|
|
1410
|
+
def _in_stored_order(values, order: tuple[int, ...], spatial: tuple[int, ...]):
|
|
1411
|
+
"""Per-axis ``values`` given in a presented order (``order[k]`` is the stored axis at
|
|
1412
|
+
place ``k``), in the store's: one per axis, or one per spatial axis. A scalar, None or a
|
|
1413
|
+
list of another length passes through."""
|
|
1414
|
+
if values is None or np.isscalar(values):
|
|
1415
|
+
return values
|
|
1416
|
+
values = tuple(values)
|
|
1417
|
+
if len(values) == len(order):
|
|
1418
|
+
stored = [None] * len(order)
|
|
1419
|
+
for place, axis in enumerate(order):
|
|
1420
|
+
stored[axis] = values[place]
|
|
1421
|
+
return tuple(stored)
|
|
1422
|
+
presented = [axis for axis in order if axis in spatial]
|
|
1423
|
+
if len(values) == len(presented):
|
|
1424
|
+
stored = [None] * len(spatial)
|
|
1425
|
+
for place, axis in enumerate(presented):
|
|
1426
|
+
stored[spatial.index(axis)] = values[place]
|
|
1427
|
+
return tuple(stored)
|
|
1428
|
+
return values
|
|
1429
|
+
|
|
1430
|
+
|
|
1340
1431
|
def _reopen(path: str, opts: dict, recipe=None) -> WorldArray:
|
|
1341
1432
|
return WorldArray(path, context=from_recipe(recipe), **opts)
|
|
1342
1433
|
|
|
@@ -1357,13 +1448,14 @@ def metadata(
|
|
|
1357
1448
|
units: Units = None,
|
|
1358
1449
|
open: bool = True,
|
|
1359
1450
|
retries: Retry | None = None,
|
|
1451
|
+
axes_order: AxesOrder = "stored",
|
|
1360
1452
|
) -> ArrayMeta:
|
|
1361
1453
|
"""The ``ArrayMeta`` of an array (or of ``level`` under a group), in native units unless
|
|
1362
|
-
``units`` says otherwise. ``open=False``
|
|
1363
|
-
tensorstore opened. ``retries`` times out and
|
|
1364
|
-
(Behaviour 6)."""
|
|
1454
|
+
``units`` says otherwise, its axes in ``axes_order`` (Conventions 2). ``open=False``
|
|
1455
|
+
reads it from the documents alone, with no tensorstore opened. ``retries`` times out and
|
|
1456
|
+
retries each document read and the open (Behaviour 6)."""
|
|
1365
1457
|
if open:
|
|
1366
|
-
vol = WorldArray(path, level=level, units=units, retries=retries)
|
|
1458
|
+
vol = WorldArray(path, level=level, units=units, retries=retries, axes_order=axes_order)
|
|
1367
1459
|
return vol.meta if units is None else vol.grid
|
|
1368
1460
|
node = detect(path)
|
|
1369
1461
|
if level == "first":
|
|
@@ -1391,6 +1483,7 @@ def metadata(
|
|
|
1391
1483
|
meta = read_meta_light(detect(ref.path))
|
|
1392
1484
|
else:
|
|
1393
1485
|
meta = read_meta_light(node)
|
|
1486
|
+
meta = meta.permuted(axes_permutation(meta.axes, axes_order))
|
|
1394
1487
|
if units is None:
|
|
1395
1488
|
return meta
|
|
1396
1489
|
space, time = _unit_targets(units)
|
|
@@ -21,11 +21,11 @@ every open today; pass the same object twice to share pools (spec, Behaviour 7).
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
import atexit
|
|
24
25
|
import json
|
|
25
26
|
import os
|
|
26
27
|
import tempfile
|
|
27
28
|
import threading
|
|
28
|
-
import time
|
|
29
29
|
import weakref
|
|
30
30
|
from typing import Any
|
|
31
31
|
|
|
@@ -50,12 +50,19 @@ _lock = threading.Lock()
|
|
|
50
50
|
# `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
|
|
51
51
|
# for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
|
|
52
52
|
# then strands nothing on a plain context.
|
|
53
|
+
#
|
|
54
|
+
# The thread stops at exit, before the interpreter finalizes. Python up to 3.13 ends a
|
|
55
|
+
# daemon thread that takes the GIL back during finalization with pthread_exit, and inside
|
|
56
|
+
# a tensorstore call that aborts the process: "terminate called without an active
|
|
57
|
+
# exception", exit 134, once all the work is done (mesh-n-bone's CI, 2026-10-26).
|
|
53
58
|
KICK_EVERY = 1.0
|
|
59
|
+
KICK_TIMEOUT = 10.0
|
|
54
60
|
_KICK_DIR = tempfile.gettempdir()
|
|
55
61
|
_KICK_KEY = "worldarray-kick-no-such-key"
|
|
56
62
|
_watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
|
57
63
|
_watch_lock = threading.Lock()
|
|
58
64
|
_watchdog: threading.Thread | None = None
|
|
65
|
+
_stop = threading.Event()
|
|
59
66
|
|
|
60
67
|
|
|
61
68
|
def _kickers(context: ts.Context) -> tuple:
|
|
@@ -63,7 +70,7 @@ def _kickers(context: ts.Context) -> tuple:
|
|
|
63
70
|
decoding pool, reading a missing key from the other runs on its file I/O pool."""
|
|
64
71
|
array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
|
|
65
72
|
kv = ts.KvStore.open({"driver": "file", "path": _KICK_DIR + "/"}, context=context)
|
|
66
|
-
return array.result(timeout=
|
|
73
|
+
return array.result(timeout=KICK_TIMEOUT), kv.result(timeout=KICK_TIMEOUT)
|
|
67
74
|
|
|
68
75
|
|
|
69
76
|
def kick(context: ts.Context) -> list:
|
|
@@ -86,14 +93,15 @@ def kick(context: ts.Context) -> list:
|
|
|
86
93
|
|
|
87
94
|
def watch(context: ts.Context | None) -> ts.Context | None:
|
|
88
95
|
"""Kick ``context``'s pools every ``KICK_EVERY`` seconds for as long as it lives, starting
|
|
89
|
-
the watchdog thread on first use. Returns
|
|
96
|
+
the watchdog thread on first use (not once it has stopped at exit). Returns
|
|
97
|
+
``context``."""
|
|
90
98
|
global _watchdog
|
|
91
99
|
if context is None:
|
|
92
100
|
return None
|
|
93
101
|
with _watch_lock:
|
|
94
102
|
if context not in _watched:
|
|
95
103
|
_watched[context] = None
|
|
96
|
-
if _watchdog is None or not _watchdog.is_alive():
|
|
104
|
+
if not _stop.is_set() and (_watchdog is None or not _watchdog.is_alive()):
|
|
97
105
|
_watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
|
|
98
106
|
_watchdog.start()
|
|
99
107
|
return context
|
|
@@ -101,16 +109,26 @@ def watch(context: ts.Context | None) -> ts.Context | None:
|
|
|
101
109
|
|
|
102
110
|
def _watch() -> None:
|
|
103
111
|
held: list = []
|
|
104
|
-
while
|
|
105
|
-
time.sleep(KICK_EVERY)
|
|
112
|
+
while not _stop.wait(KICK_EVERY):
|
|
106
113
|
with _watch_lock:
|
|
107
114
|
contexts = list(_watched.keys())
|
|
108
115
|
held = [f for f in held if not f.done()]
|
|
109
116
|
for context in contexts:
|
|
117
|
+
if _stop.is_set():
|
|
118
|
+
break
|
|
110
119
|
held += kick(context)
|
|
111
120
|
del contexts
|
|
112
121
|
|
|
113
122
|
|
|
123
|
+
def _stop_watchdog() -> None:
|
|
124
|
+
"""End every watchdog thread and wait for it, at exit: atexit runs once the program's
|
|
125
|
+
other threads are joined, so they are kicked to the end, and before finalization."""
|
|
126
|
+
_stop.set()
|
|
127
|
+
for thread in threading.enumerate():
|
|
128
|
+
if thread.name == "worldarray-kick" and thread is not threading.current_thread():
|
|
129
|
+
thread.join(2 * KICK_TIMEOUT) # the longest one kick takes: two opens
|
|
130
|
+
|
|
131
|
+
|
|
114
132
|
def _forget_watchdog() -> None:
|
|
115
133
|
global _watchdog
|
|
116
134
|
_watchdog = None # a forked child has no such thread; the next watch() starts one
|
|
@@ -118,6 +136,7 @@ def _forget_watchdog() -> None:
|
|
|
118
136
|
|
|
119
137
|
if hasattr(os, "register_at_fork"):
|
|
120
138
|
os.register_at_fork(after_in_child=_forget_watchdog)
|
|
139
|
+
atexit.register(_stop_watchdog)
|
|
121
140
|
|
|
122
141
|
|
|
123
142
|
THREADS_VAR = "WORLDARRAY_THREADS"
|
|
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
19
|
import re
|
|
20
|
+
from collections.abc import Sequence
|
|
20
21
|
from dataclasses import dataclass, field, replace
|
|
21
22
|
|
|
22
23
|
import numpy as np
|
|
@@ -143,12 +144,82 @@ class ArrayMeta:
|
|
|
143
144
|
shard_shape=pick(self.shard_shape) if self.shard_shape is not None else None,
|
|
144
145
|
)
|
|
145
146
|
|
|
147
|
+
def permuted(self, order: Sequence[int]) -> ArrayMeta:
|
|
148
|
+
"""The same metadata with axis ``order[k]`` at place ``k``: every per-axis field moves
|
|
149
|
+
with its name."""
|
|
150
|
+
pick = lambda t: tuple(t[i] for i in order) # noqa: E731
|
|
151
|
+
return replace(
|
|
152
|
+
self,
|
|
153
|
+
shape=pick(self.shape),
|
|
154
|
+
chunk_shape=pick(self.chunk_shape),
|
|
155
|
+
axes=pick(self.axes),
|
|
156
|
+
units=pick(self.units),
|
|
157
|
+
voxel_size=pick(self.voxel_size),
|
|
158
|
+
translation=pick(self.translation),
|
|
159
|
+
shard_shape=pick(self.shard_shape) if self.shard_shape is not None else None,
|
|
160
|
+
)
|
|
161
|
+
|
|
146
162
|
@property
|
|
147
163
|
def write_shape(self) -> tuple[int, ...]:
|
|
148
164
|
"""The unit one writer may own: the shard shape when sharded, else the chunk shape."""
|
|
149
165
|
return self.shard_shape if self.shard_shape is not None else self.chunk_shape
|
|
150
166
|
|
|
151
167
|
|
|
168
|
+
AxesOrder = str | Sequence[str]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check_axes_order(order: AxesOrder) -> str | tuple[str, ...]:
|
|
172
|
+
"""``"stored"``, ``"zyx"`` or a tuple of axis names (Conventions 2)."""
|
|
173
|
+
if isinstance(order, str):
|
|
174
|
+
if order not in ("stored", "zyx"):
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"axes_order is 'stored', 'zyx' or a sequence of axis names, not {order!r}"
|
|
177
|
+
)
|
|
178
|
+
return order
|
|
179
|
+
names = tuple(str(a) for a in order)
|
|
180
|
+
if len(set(names)) != len(names):
|
|
181
|
+
raise ValueError(f"axes_order {names} names an axis twice")
|
|
182
|
+
return names
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def axes_permutation(
|
|
186
|
+
axes: Sequence[str], order: AxesOrder = "stored", fixed: Sequence[str] = ()
|
|
187
|
+
) -> tuple[int, ...]:
|
|
188
|
+
"""The axis of ``axes`` at each place once ``order`` presents them (Conventions 2):
|
|
189
|
+
``"stored"`` keeps them; ``"zyx"`` moves the axes named z, y and x last, in that order,
|
|
190
|
+
the others first in stored order; a sequence of names is that order. Axes named in
|
|
191
|
+
``fixed`` (selected ones, still in a key index or a pyramid's ``level_meta``) keep their
|
|
192
|
+
places, and the others fill the rest in order."""
|
|
193
|
+
axes = tuple(axes)
|
|
194
|
+
order = check_axes_order(order)
|
|
195
|
+
movable = [i for i, a in enumerate(axes) if a not in fixed]
|
|
196
|
+
if order == "stored":
|
|
197
|
+
ranked = movable
|
|
198
|
+
elif order == "zyx":
|
|
199
|
+
named = sorted(
|
|
200
|
+
(i for i in movable if axes[i] in SPATIAL_NAMES), key=lambda i: "zyx".index(axes[i])
|
|
201
|
+
)
|
|
202
|
+
ranked = [i for i in movable if axes[i] not in SPATIAL_NAMES] + named
|
|
203
|
+
else:
|
|
204
|
+
names = [axes[i] for i in movable]
|
|
205
|
+
if sorted(order) != sorted(names):
|
|
206
|
+
raise ValueError(f"axes_order {order} must name each of the axes {tuple(names)} once")
|
|
207
|
+
ranked = [movable[names.index(a)] for a in order]
|
|
208
|
+
places = iter(ranked)
|
|
209
|
+
return tuple(i if axes[i] in fixed else next(places) for i in range(len(axes)))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def spatial_order(axes: Sequence[str], order: AxesOrder = "stored") -> tuple[int, ...]:
|
|
213
|
+
"""The spatial axes of ``axes`` in the order ``order`` presents them."""
|
|
214
|
+
axes = tuple(axes)
|
|
215
|
+
sp = spatial_axes(axes)
|
|
216
|
+
others = tuple(a for i, a in enumerate(axes) if i not in sp)
|
|
217
|
+
order = check_axes_order(order)
|
|
218
|
+
if not isinstance(order, str):
|
|
219
|
+
order = tuple(a for a in order if a in {axes[i] for i in sp})
|
|
220
|
+
return tuple(i for i in axes_permutation(axes, order, fixed=others) if i in sp)
|
|
221
|
+
|
|
222
|
+
|
|
152
223
|
def spatial_axes(axes: tuple[str, ...]) -> tuple[int, ...]:
|
|
153
224
|
named = tuple(i for i, a in enumerate(axes) if a in SPATIAL_NAMES)
|
|
154
225
|
if named:
|
|
@@ -730,29 +801,32 @@ def _precomputed(info: dict, scale_index: int, ndim: int) -> _Resolved:
|
|
|
730
801
|
|
|
731
802
|
|
|
732
803
|
def light_voxel_sizes(
|
|
733
|
-
group: Node, names: list[str], space: str | None
|
|
804
|
+
group: Node, names: list[str], space: str | None, axes_order: AxesOrder = "stored"
|
|
734
805
|
) -> list[tuple[str, tuple[float, ...]]] | None:
|
|
735
|
-
levels = light_levels(group, names, space)
|
|
806
|
+
levels = light_levels(group, names, space, axes_order)
|
|
736
807
|
return None if levels is None else [(name, vs) for name, vs, _ in levels]
|
|
737
808
|
|
|
738
809
|
|
|
739
810
|
def light_levels(
|
|
740
|
-
group: Node, names: list[str], space: str | None
|
|
811
|
+
group: Node, names: list[str], space: str | None, axes_order: AxesOrder = "stored"
|
|
741
812
|
) -> list[tuple[str, tuple[float, ...], tuple[float, ...]]] | None:
|
|
742
813
|
"""``(name, spatial voxel size)`` for every level of a group, from the group's own
|
|
743
814
|
document, in ``space`` units (native when None): OME-NGFF datasets, COSEM N5
|
|
744
815
|
``multiscales[].datasets[].transform`` and precomputed ``scales``. None when the
|
|
745
816
|
document does not say (then the levels have to be opened), or when a unit cannot be
|
|
746
817
|
converted. ``light_levels`` adds each level's spatial translation (the centre of its
|
|
747
|
-
voxel 0, in the same units), so a pyramid can be placed without opening any level.
|
|
818
|
+
voxel 0, in the same units), so a pyramid can be placed without opening any level.
|
|
819
|
+
Both on the spatial axes in the order ``axes_order`` presents them."""
|
|
748
820
|
try:
|
|
749
821
|
if group.format == "precomputed":
|
|
750
822
|
f = U.factor("nm", space) if space else 1.0
|
|
823
|
+
order = spatial_order(("z", "y", "x"), axes_order)
|
|
751
824
|
out = []
|
|
752
825
|
for i, scale in enumerate(group.docs["info"]["scales"]):
|
|
753
826
|
res = [float(v) * f for v in scale["resolution"]][::-1]
|
|
754
827
|
vo = [float(v) for v in scale.get("voxel_offset", [0, 0, 0])][::-1]
|
|
755
828
|
tr = [(o + 0.5) * r for o, r in zip(vo, res)]
|
|
829
|
+
res, tr = [res[k] for k in order], [tr[k] for k in order]
|
|
756
830
|
out.append((str(scale.get("key", i)), tuple(res), tuple(tr)))
|
|
757
831
|
return out
|
|
758
832
|
multiscales = group.multiscales
|
|
@@ -788,7 +862,7 @@ def light_levels(
|
|
|
788
862
|
axes = found.axes or default_axes(ndim)
|
|
789
863
|
units = found.units or ("",) * ndim
|
|
790
864
|
sizes, trs = [], []
|
|
791
|
-
for i in
|
|
865
|
+
for i in spatial_order(axes, axes_order):
|
|
792
866
|
f = U.factor(units[i], space, missing_as=U.NANOMETER) if space else 1.0
|
|
793
867
|
sizes.append(found.voxel_size[i] * f)
|
|
794
868
|
trs.append((found.translation[i] if found.translation is not None else 0.0) * f)
|
|
@@ -8,6 +8,9 @@ import http.server
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
10
|
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import textwrap
|
|
11
14
|
import threading
|
|
12
15
|
import time
|
|
13
16
|
import weakref
|
|
@@ -205,6 +208,32 @@ def test_every_context_the_package_touches_is_kicked(monkeypatch, tmp_path):
|
|
|
205
208
|
assert len(wa.context._watched) == n - 1 # a context that died is no longer watched
|
|
206
209
|
|
|
207
210
|
|
|
211
|
+
def test_a_process_exits_cleanly_while_the_watchdog_kicks(tmp_path):
|
|
212
|
+
# Python up to 3.13 ends a daemon thread that takes the GIL back during finalization
|
|
213
|
+
# with pthread_exit, which aborts inside a tensorstore call; the watchdog stops at exit
|
|
214
|
+
script = tmp_path / "exit.py"
|
|
215
|
+
script.write_text(
|
|
216
|
+
textwrap.dedent("""
|
|
217
|
+
import time
|
|
218
|
+
import tensorstore as ts
|
|
219
|
+
from worldarray import context
|
|
220
|
+
context.KICK_EVERY = 0.0 # kick flat out
|
|
221
|
+
limits = [{"cache_pool": {"total_bytes_limit": i}} for i in range(20)]
|
|
222
|
+
contexts = [context.watch(ts.Context(spec)) for spec in limits]
|
|
223
|
+
class SlowExit: # finalization takes a while, with the GIL free
|
|
224
|
+
def __del__(self):
|
|
225
|
+
time.sleep(0.5)
|
|
226
|
+
slow = SlowExit()
|
|
227
|
+
time.sleep(0.2)
|
|
228
|
+
""")
|
|
229
|
+
)
|
|
230
|
+
done = subprocess.run(
|
|
231
|
+
[sys.executable, str(script)], capture_output=True, text=True, timeout=120
|
|
232
|
+
)
|
|
233
|
+
assert done.returncode == 0, done.stderr
|
|
234
|
+
assert "terminate" not in done.stderr
|
|
235
|
+
|
|
236
|
+
|
|
208
237
|
class _StalledView:
|
|
209
238
|
"""A view whose first ``stalls`` reads never finish (a future nothing resolves, as a
|
|
210
239
|
read stuck on NFS looks from Python); later reads return ``0, 1, 2, 3``."""
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Presenting the axes in another order: ``axes_order`` (spec, Conventions 2, Behaviours 14
|
|
2
|
+
and 15)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import pickle
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import worldarray as wa
|
|
12
|
+
from conftest import pattern, write_n5, write_zarr2
|
|
13
|
+
|
|
14
|
+
XYZ = ("x", "y", "z")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _xyz(tmp_path, shape=(8, 6, 4), **kw) -> tuple[str, np.ndarray]:
|
|
18
|
+
"""An x, y, z array (TensorSwitch's order) at 16, 16, 45 nm, filled with a pattern."""
|
|
19
|
+
path = str(tmp_path / "xyz.zarr/s0")
|
|
20
|
+
vol = wa.create(path, shape, "uint16", (16, 16, 45), (4, 4, 0), axes=XYZ, **kw)
|
|
21
|
+
data = pattern(shape)
|
|
22
|
+
vol.write_index((0,) * len(shape), shape, data)
|
|
23
|
+
return path, data
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_zyx_presents_an_xyz_array_as_its_transpose(tmp_path):
|
|
27
|
+
path, data = _xyz(tmp_path, chunks=(4, 3, 2))
|
|
28
|
+
stored = wa.open(path)
|
|
29
|
+
assert stored.axes == XYZ and stored.shape == (8, 6, 4) # the default is the store's order
|
|
30
|
+
vol = wa.open(path, axes_order="zyx")
|
|
31
|
+
assert vol.axes == ("z", "y", "x") and vol.shape == (4, 6, 8) and vol.ts.shape == (4, 6, 8)
|
|
32
|
+
assert vol.voxel_size == (45, 16, 16) and vol.translation == (0, 4, 4)
|
|
33
|
+
assert vol.meta.chunk_shape == (2, 3, 4) and vol.write_shape == (2, 3, 4)
|
|
34
|
+
assert vol.meta.units == ("nanometer",) * 3
|
|
35
|
+
np.testing.assert_array_equal(vol.read_index((0, 0, 0), (4, 6, 8)), data.transpose(2, 1, 0))
|
|
36
|
+
np.testing.assert_array_equal(vol.read(vol.box), data.transpose(2, 1, 0))
|
|
37
|
+
part = vol.read_index((1, 2, 3), (3, 5, 7))
|
|
38
|
+
np.testing.assert_array_equal(part, data[3:7, 2:5, 1:3].transpose(2, 1, 0))
|
|
39
|
+
assert wa.Grid.from_meta(vol.meta).axes == ("z", "y", "x")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_a_channel_axis_comes_first_and_select_still_pins_it(tmp_path):
|
|
43
|
+
path = str(tmp_path / "cxyz.zarr/s0")
|
|
44
|
+
data = pattern((2, 8, 6, 4))
|
|
45
|
+
wa.create(
|
|
46
|
+
path, data.shape, "uint16", (16, 16, 45), (4, 4, 0), axes=("c", *XYZ), chunks=(1, 4, 3, 2)
|
|
47
|
+
)
|
|
48
|
+
wa.open(path, mode="r+").write_index((0,) * 4, data.shape, data)
|
|
49
|
+
vol = wa.open(path, axes_order="zyx")
|
|
50
|
+
assert vol.axes == ("c", "z", "y", "x") and vol.meta.chunk_shape == (1, 2, 3, 4)
|
|
51
|
+
np.testing.assert_array_equal(vol.read_index((0,) * 4, vol.shape), data.transpose(0, 3, 2, 1))
|
|
52
|
+
one = wa.open(path, axes_order="zyx", select={"c": 1})
|
|
53
|
+
assert one.axes == ("z", "y", "x")
|
|
54
|
+
np.testing.assert_array_equal(one.read_index((0,) * 3, one.shape), data[1].transpose(2, 1, 0))
|
|
55
|
+
# a channel-last array comes out channel first
|
|
56
|
+
last = str(tmp_path / "xyzc.zarr/s0")
|
|
57
|
+
wa.create(
|
|
58
|
+
last, (8, 6, 4, 2), "uint16", (16, 16, 45), (4, 4, 0), axes=(*XYZ, "c"), chunks=(4, 3, 2, 1)
|
|
59
|
+
)
|
|
60
|
+
assert wa.open(last, axes_order="zyx").axes == ("c", "z", "y", "x")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_write_through_the_reordered_view_lands_in_the_stores_order(tmp_path):
|
|
64
|
+
path = str(tmp_path / "w.zarr/s0")
|
|
65
|
+
wa.create(
|
|
66
|
+
path,
|
|
67
|
+
(8, 6, 4),
|
|
68
|
+
"uint16",
|
|
69
|
+
(16, 16, 45),
|
|
70
|
+
(4, 4, 0),
|
|
71
|
+
axes=XYZ,
|
|
72
|
+
chunks=(2, 3, 2),
|
|
73
|
+
shards=(4, 6, 2),
|
|
74
|
+
)
|
|
75
|
+
vol = wa.open(path, mode="r+", axes_order="zyx")
|
|
76
|
+
assert vol.meta.shard_shape == (2, 6, 4) and vol.write_shape == (2, 6, 4)
|
|
77
|
+
zyx = pattern((4, 6, 8))
|
|
78
|
+
vol.write_index((0, 0, 0), (2, 6, 4), zyx[:2, :, :4]) # one shard: z 0-2, x 0-4
|
|
79
|
+
vol.write_chunk((1, 0, 1), zyx[2:, :, 4:])
|
|
80
|
+
stored = wa.open(path)
|
|
81
|
+
np.testing.assert_array_equal(
|
|
82
|
+
stored.read_index((0, 0, 0), (4, 6, 2)), zyx[:2, :, :4].transpose(2, 1, 0)
|
|
83
|
+
)
|
|
84
|
+
np.testing.assert_array_equal(
|
|
85
|
+
stored.read_index((4, 0, 2), (8, 6, 4)), zyx[2:, :, 4:].transpose(2, 1, 0)
|
|
86
|
+
)
|
|
87
|
+
with pytest.raises(ValueError, match="whole write units"):
|
|
88
|
+
vol.write_index((0, 0, 0), (1, 6, 4), zyx[:1, :, :4])
|
|
89
|
+
# keys are the store's; the indices are in the presented order
|
|
90
|
+
assert vol.chunk_key((1, 0, 1)) == stored.chunk_key((1, 0, 1)[::-1]) == "c/1/0/1"
|
|
91
|
+
assert vol.chunk_key((0, 0, 1)) == "c/1/0/0"
|
|
92
|
+
assert vol.parse_chunk_key("c/1/0/0") == (0, 0, 1)
|
|
93
|
+
assert vol.list_chunks() == [(0, 0, 0), (1, 0, 1)]
|
|
94
|
+
assert stored.list_chunks() == [(0, 0, 0), (1, 0, 1)]
|
|
95
|
+
for b in vol.blocks():
|
|
96
|
+
assert vol.parse_chunk_key(vol.chunk_key(b.index)) == b.index
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_selected_axis_keeps_its_stored_place_in_a_key_index(tmp_path):
|
|
100
|
+
path = str(tmp_path / "k.zarr/s0")
|
|
101
|
+
wa.create(
|
|
102
|
+
path, (8, 6, 4, 2), "uint16", (16, 16, 45), (4, 4, 0), axes=(*XYZ, "c"), chunks=(4, 3, 2, 1)
|
|
103
|
+
)
|
|
104
|
+
vol = wa.open(path, axes_order="zyx", select={"c": 1})
|
|
105
|
+
assert vol.axes == ("z", "y", "x")
|
|
106
|
+
# stored (x, y, z, c); the key index is (z, y, x, c): c stays last, where it is stored
|
|
107
|
+
assert vol.chunk_key((1, 0, 1, 1)) == "c/1/0/1/1"
|
|
108
|
+
assert vol.chunk_key((0, 1, 1, 0)) == "c/1/1/0/0"
|
|
109
|
+
assert vol.parse_chunk_key("c/1/1/0/0") == (0, 1, 1, 0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_a_sequence_names_the_order_and_a_bad_order_is_refused(tmp_path):
|
|
113
|
+
path, data = _xyz(tmp_path, chunks=(4, 3, 2))
|
|
114
|
+
vol = wa.open(path, axes_order=("y", "z", "x"))
|
|
115
|
+
assert vol.axes == ("y", "z", "x") and vol.voxel_size == (16, 45, 16)
|
|
116
|
+
np.testing.assert_array_equal(vol.read_index((0, 0, 0), vol.shape), data.transpose(1, 2, 0))
|
|
117
|
+
with pytest.raises(ValueError, match="axes_order is"):
|
|
118
|
+
wa.open(path, axes_order="xyz")
|
|
119
|
+
with pytest.raises(ValueError, match="must name each of the axes"):
|
|
120
|
+
wa.open(path, axes_order=("z", "y"))
|
|
121
|
+
with pytest.raises(ValueError, match="names an axis twice"):
|
|
122
|
+
wa.open(path, axes_order=("z", "z", "x"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_n5_and_unnamed_arrays_are_unchanged_under_zyx(tmp_path):
|
|
126
|
+
data = pattern((4, 6, 8))
|
|
127
|
+
write_n5(tmp_path / "a.n5", data, attrs={"resolution": [16, 16, 45], "axes": ["x", "y", "z"]})
|
|
128
|
+
n5 = wa.open(str(tmp_path / "a.n5"), axes_order="zyx", units=None)
|
|
129
|
+
assert n5.axes == ("z", "y", "x") and n5.voxel_size == (45, 16, 16)
|
|
130
|
+
np.testing.assert_array_equal(n5.read_index((0, 0, 0), n5.shape), data)
|
|
131
|
+
write_zarr2(tmp_path / "u.zarr", data, chunks=(2, 3, 4))
|
|
132
|
+
unnamed = wa.open(str(tmp_path / "u.zarr"), axes_order="zyx", units=None)
|
|
133
|
+
assert unnamed.axes == ("z", "y", "x") and not unnamed.meta.axes_named
|
|
134
|
+
np.testing.assert_array_equal(unnamed.read_index((0, 0, 0), unnamed.shape), data)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_metadata_with_and_without_opening(tmp_path):
|
|
138
|
+
path, _ = _xyz(tmp_path, chunks=(4, 3, 2))
|
|
139
|
+
for opened in (True, False):
|
|
140
|
+
meta = wa.metadata(path, axes_order="zyx", open=opened)
|
|
141
|
+
assert meta.axes == ("z", "y", "x") and meta.shape == (4, 6, 8)
|
|
142
|
+
assert meta.chunk_shape == (2, 3, 4) and meta.voxel_size == (45, 16, 16)
|
|
143
|
+
assert meta.translation == (0, 4, 4)
|
|
144
|
+
assert wa.metadata(path, open=False).axes == XYZ
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _xyz_pyramid(tmp_path) -> str:
|
|
148
|
+
"""s0 at 16, 16, 45 nm and s1 at 32, 32, 45 (x and y halved), stored x, y, z."""
|
|
149
|
+
_xyz(tmp_path, chunks=(4, 3, 2))
|
|
150
|
+
group = str(tmp_path / "xyz.zarr")
|
|
151
|
+
wa.open_pyramid(group, mode="r+").add_level("s1", factors=(2, 2, 1))
|
|
152
|
+
return group
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_a_pyramid_presents_its_placements_and_level_meta_in_the_order(tmp_path):
|
|
156
|
+
group = _xyz_pyramid(tmp_path)
|
|
157
|
+
pyr = wa.open_pyramid(group, axes_order="zyx")
|
|
158
|
+
assert pyr.placements() == [
|
|
159
|
+
("s0", (45.0, 16.0, 16.0), (0.0, 4.0, 4.0)),
|
|
160
|
+
("s1", (45.0, 32.0, 32.0), (0.0, 12.0, 12.0)),
|
|
161
|
+
]
|
|
162
|
+
assert pyr.level_meta("s1").shape == (4, 3, 4) and pyr.level_meta("s1").axes == ("z", "y", "x")
|
|
163
|
+
assert pyr.by_name("s1").shape == (4, 3, 4) and pyr.by_name("s1").axes == ("z", "y", "x")
|
|
164
|
+
# a level chosen by an anisotropic voxel size, given in the presented order
|
|
165
|
+
vol = wa.open(group, voxel_size=(45, 32, 32), axes_order="zyx")
|
|
166
|
+
assert vol.level_name == "s1" and vol.voxel_size == (45, 32, 32)
|
|
167
|
+
with pytest.raises(ValueError, match="no level at voxel size"):
|
|
168
|
+
wa.open(group, voxel_size=(45, 32, 32)) # read as x, y, z: no such level
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_base_voxel_size_lands_on_the_presented_axes(tmp_path):
|
|
172
|
+
group = _xyz_pyramid(tmp_path)
|
|
173
|
+
vol = wa.open(group, level="s1", axes_order="zyx", base_voxel_size=(450, 160, 160))
|
|
174
|
+
assert vol.voxel_size == (450, 320, 320)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_add_level_takes_its_arguments_in_the_presented_order(tmp_path):
|
|
178
|
+
group = _xyz_pyramid(tmp_path)
|
|
179
|
+
pyr = wa.open_pyramid(group, mode="r+", axes_order="zyx")
|
|
180
|
+
s2 = pyr.add_level("s2", factors=(2, 2, 2), from_level="s1")
|
|
181
|
+
s3 = pyr.add_level("s3", factors=(1, 2, 2), from_level="s1", chunks=(2, 3, 4))
|
|
182
|
+
assert s2.axes == s3.axes == ("z", "y", "x")
|
|
183
|
+
assert s2.voxel_size == (90, 64, 64) and s3.voxel_size == (45, 64, 64)
|
|
184
|
+
stored = wa.open(f"{group}/s3")
|
|
185
|
+
assert stored.axes == XYZ and stored.voxel_size == (64, 64, 45)
|
|
186
|
+
assert stored.shape == (2, 2, 4) and stored.meta.chunk_shape == (4, 3, 2)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_the_order_survives_reopen_and_a_pickle(tmp_path):
|
|
190
|
+
path, data = _xyz(tmp_path, chunks=(4, 3, 2))
|
|
191
|
+
vol = wa.open(path, axes_order="zyx")
|
|
192
|
+
for again in (vol.reopen(), pickle.loads(pickle.dumps(vol))):
|
|
193
|
+
assert again.axes == ("z", "y", "x")
|
|
194
|
+
np.testing.assert_array_equal(
|
|
195
|
+
again.read_index((0, 0, 0), again.shape), data.transpose(2, 1, 0)
|
|
196
|
+
)
|
|
197
|
+
assert vol.reopen(axes_order="stored").axes == XYZ
|
|
@@ -1247,7 +1247,7 @@ wheels = [
|
|
|
1247
1247
|
|
|
1248
1248
|
[[package]]
|
|
1249
1249
|
name = "worldarray"
|
|
1250
|
-
version = "0.
|
|
1250
|
+
version = "0.10.0"
|
|
1251
1251
|
source = { editable = "." }
|
|
1252
1252
|
dependencies = [
|
|
1253
1253
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|