worldarray 0.3.1__tar.gz → 0.4.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 (36) hide show
  1. {worldarray-0.3.1 → worldarray-0.4.0}/CHANGELOG.md +27 -2
  2. {worldarray-0.3.1 → worldarray-0.4.0}/PKG-INFO +7 -6
  3. {worldarray-0.3.1 → worldarray-0.4.0}/README.md +6 -5
  4. {worldarray-0.3.1 → worldarray-0.4.0}/docs/spec.md +72 -17
  5. {worldarray-0.3.1 → worldarray-0.4.0}/pyproject.toml +1 -1
  6. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/array.py +103 -11
  8. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/write.py +10 -1
  9. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_read.py +6 -0
  10. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_resample.py +42 -0
  11. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_write.py +51 -0
  12. {worldarray-0.3.1 → worldarray-0.4.0}/uv.lock +1 -1
  13. {worldarray-0.3.1 → worldarray-0.4.0}/.github/workflows/ci.yml +0 -0
  14. {worldarray-0.3.1 → worldarray-0.4.0}/.github/workflows/docs.yml +0 -0
  15. {worldarray-0.3.1 → worldarray-0.4.0}/.github/workflows/publish.yml +0 -0
  16. {worldarray-0.3.1 → worldarray-0.4.0}/.gitignore +0 -0
  17. {worldarray-0.3.1 → worldarray-0.4.0}/LICENSE +0 -0
  18. {worldarray-0.3.1 → worldarray-0.4.0}/docs/changelog.md +0 -0
  19. {worldarray-0.3.1 → worldarray-0.4.0}/docs/index.md +0 -0
  20. {worldarray-0.3.1 → worldarray-0.4.0}/mkdocs.yml +0 -0
  21. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/context.py +0 -0
  22. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/geometry.py +0 -0
  23. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/grid.py +0 -0
  24. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/levels.py +0 -0
  25. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/placement.py +0 -0
  26. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/resample.py +0 -0
  27. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/retry.py +0 -0
  28. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/store.py +0 -0
  29. {worldarray-0.3.1 → worldarray-0.4.0}/src/worldarray/units.py +0 -0
  30. {worldarray-0.3.1 → worldarray-0.4.0}/tests/conftest.py +0 -0
  31. {worldarray-0.3.1 → worldarray-0.4.0}/tests/fixtures/metadata.json +0 -0
  32. {worldarray-0.3.1 → worldarray-0.4.0}/tests/fixtures/write.json +0 -0
  33. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_access.py +0 -0
  34. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_geometry.py +0 -0
  35. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_grid.py +0 -0
  36. {worldarray-0.3.1 → worldarray-0.4.0}/tests/test_metadata.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-10-22)
4
+
5
+ cellmap-analyze's three asks, so it can call worldarray directly with no wrapper, and two
6
+ of cellmap-flow's. No placement change and no unit table change.
7
+
8
+ - `vol.read_onto(grid, begin=None, end=None, ...)` reads voxels `[begin, end)` of a
9
+ `Grid`'s spatial axes from the array, exactly that shape (Behaviour 20). It is the
10
+ `read` of the grid's index box at the grid's voxel size anchored at its corner, so at
11
+ the array's own voxel size nothing is resampled, whatever the sub-voxel offset, and
12
+ otherwise the array is resampled onto the grid. The grid's units are converted.
13
+ - A pickled handle, and `reopen`, use the path resolved against the working directory at
14
+ open, so a worker that changes directory before unpickling opens the same store; it
15
+ used to raise `FileNotFoundError` for a relative path.
16
+ - `write_index`, `write` and `write_chunk` take a scalar, which fills the box. It is
17
+ converted to the dtype only when its value survives (`0` into `uint8`; `300`, `-1`, `0.5`
18
+ and NaN raise), and the fill value over whole units leaves them absent.
19
+ - `vol.source_translation`: the stored level's translation in `units`, beside
20
+ `source_voxel_size`, under resample and relabel too (cellmap-flow's `stored_placement`).
21
+ - `create(exists="adopt")` also compares the keys of `attrs` with the existing array's
22
+ attributes and raises naming each that differs or is missing; keys not given are not
23
+ compared. An adopt given different `attrs` used to keep the old ones silently, as funlib's
24
+ `prepare_ds` kept an old offset (cellmap-flow's `_refuse_another_region`).
25
+
3
26
  ## 0.3.1 (2026-10-22)
4
27
 
5
28
  - `source_voxel_size` reports the stored level's voxel size under `on_mismatch="relabel"`,
@@ -10,8 +33,10 @@
10
33
  `Grid.from_meta(meta, voxel_size=t).box` already was; it was the stored data's extent, so
11
34
  `read(box)` could return fewer voxels than `shape` (cellmap-flow's 7 voxels of 8 nm at
12
35
  24 nm: box ended at 56 nm, now 72). No voxel moves.
13
- - The unit table knows kilometres (`kilometer`, `kilometre`, `km`, 1e3 m), so they
14
- convert, abbreviate to `km` and are written as OME's `kilometer` (chunkmirage's ask).
36
+ - **Unit table change.** The unit table knows kilometres (`kilometer`, `kilometre`, `km`,
37
+ 1e3 m), so they convert, abbreviate to `km` and are written as OME's `kilometer`
38
+ (chunkmirage's ask). This should have been a minor release (spec section 8): cellmap-flow
39
+ exports the table and its CI failed until it moved its pin to `>=0.3.1`.
15
40
  - README: the placement rule gains the corner formula, a worked example, why the centre
16
41
  is the model, and how the read rule follows from it.
17
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.3.1
3
+ Version: 0.4.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/
@@ -109,6 +109,7 @@ six = wa.open(path, voxel_size=(6, 6, 6), on_mismatch="resample")
109
109
 
110
110
  # one volume read onto another volume's grid, for masks, contact sites, statistics
111
111
  onto = raw.read(box, voxel_size=labels.voxel_size, anchor=labels.corner)
112
+ onto = raw.read_onto(grid, begin, end) # voxels [begin, end) of a Grid, exactly that shape
112
113
 
113
114
  # index space, native units: what an engine or a viewer wants
114
115
  vol = wa.open(path, units=None)
@@ -159,8 +160,8 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
159
160
  - **Plans outputs and blocks** (0.3): a store-less `Grid` with the same geometry as an
160
161
  array, so an output is planned, cut into blocks and only then created; `blocks` tiling an
161
162
  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.
163
+ picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule; every
164
+ input read onto that grid by its voxel indices (0.4). Nothing is scheduled or run.
164
165
 
165
166
  ## The placement rule
166
167
 
@@ -227,12 +228,12 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
227
228
  ```
228
229
 
229
230
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
230
- `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`,
231
+ `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `source_translation`, `resampled`,
231
232
  `writable`, `write_shape`;
232
233
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
233
- `read_index(begin, end, padding=, mask=, select=, transform=)`, `index_box(box)`,
234
+ `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
234
235
  `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
235
- `write_index(begin, end, data, partial=)`, `write(box, data, partial=)`, `reopen(context=)`.
236
+ `write_index(begin, end, data, partial=)` and `write(box, data, partial=)` (`data` an array or a scalar), `reopen(context=)`.
236
237
 
237
238
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
238
239
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -50,6 +50,7 @@ six = wa.open(path, voxel_size=(6, 6, 6), on_mismatch="resample")
50
50
 
51
51
  # one volume read onto another volume's grid, for masks, contact sites, statistics
52
52
  onto = raw.read(box, voxel_size=labels.voxel_size, anchor=labels.corner)
53
+ onto = raw.read_onto(grid, begin, end) # voxels [begin, end) of a Grid, exactly that shape
53
54
 
54
55
  # index space, native units: what an engine or a viewer wants
55
56
  vol = wa.open(path, units=None)
@@ -100,8 +101,8 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
100
101
  - **Plans outputs and blocks** (0.3): a store-less `Grid` with the same geometry as an
101
102
  array, so an output is planned, cut into blocks and only then created; `blocks` tiling an
102
103
  array or a box into write boxes with context-grown read boxes, in C order, as plain
103
- picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule.
104
- Nothing is scheduled or run.
104
+ picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule; every
105
+ input read onto that grid by its voxel indices (0.4). Nothing is scheduled or run.
105
106
 
106
107
  ## The placement rule
107
108
 
@@ -168,12 +169,12 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
168
169
  ```
169
170
 
170
171
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
171
- `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`,
172
+ `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `source_translation`, `resampled`,
172
173
  `writable`, `write_shape`;
173
174
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
174
- `read_index(begin, end, padding=, mask=, select=, transform=)`, `index_box(box)`,
175
+ `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
175
176
  `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
176
- `write_index(begin, end, data, partial=)`, `write(box, data, partial=)`, `reopen(context=)`.
177
+ `write_index(begin, end, data, partial=)` and `write(box, data, partial=)` (`data` an array or a scalar), `reopen(context=)`.
177
178
 
178
179
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
179
180
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -149,8 +149,9 @@ vol.meta # ArrayMeta in the store's own units
149
149
  vol.grid # ArrayMeta in the requested units, every axis
150
150
  vol.shape, vol.dtype, vol.axes, vol.spatial, vol.level_name, vol.levels, vol.remote
151
151
  # shape is the stored shape, or with a resample target the target voxels covering the data
152
- vol.source_voxel_size, vol.resampled # the stored level's voxel size, also under resample and relabel; whether reads
153
- # resample to a target
152
+ vol.source_voxel_size, vol.source_translation, vol.resampled
153
+ # the stored level's voxel size and translation (0.4), also under resample and relabel; whether
154
+ # reads resample to a target
154
155
  vol.list_chunks() # the chunk indices that exist in storage, by listing keys (zarr v2/v3, N5, unsharded precomputed)
155
156
  vol.path # what the caller passed (a group, say); vol.level_path is the array actually opened
156
157
  vol.voxel_size # tuple[float] in `units`, spatial axes, the array's order (zyx for a volume)
@@ -285,7 +286,8 @@ vol = wa.create(
285
286
  attrs=None, # extra array attributes, merged into the array's own
286
287
  group_attrs=None, # extra group attributes, merged; no one else's key is dropped or rewritten (Behaviour 12)
287
288
  exists="error", # "error": anything already at `path` raises | "adopt": an identical array (shape, dtype, chunks,
288
- # shards, placement within rtol 1e-6) is opened for writing; a different one raises naming each
289
+ # shards, placement within rtol 1e-6, and from 0.4 every key of `attrs` holding the value given)
290
+ # is opened for writing; a different one raises naming each
289
291
  # difference. There is no third value. An adopt writes no group document when the entry
290
292
  # already says the same (Behaviour 12)
291
293
  store_fill=False, # True stores write units equal to fill_value instead of leaving them absent
@@ -337,6 +339,20 @@ wa.store.join(path, *parts) ; wa.store.parent(path) ; wa.store.basename(path)
337
339
  wa.units.abbreviation(unit) -> str # the OME short form, "nm" for any spelling of nanometre the table knows
338
340
  ```
339
341
 
342
+ ### 0.4: reading onto a grid
343
+
344
+ ```python
345
+ vol.read_onto(grid, begin=None, end=None, padding=None, method=None, mask=False, retries=None, transform=None,
346
+ post_transform=None, select=None)
347
+ # voxels [begin, end) of a Grid's spatial axes (all of it when None), read from this array:
348
+ # exactly that shape, by the centre rule at the array's voxel size, else resampled onto the
349
+ # grid anchored at its corner (Behaviour 20)
350
+ vol.write_index(begin, end, 0) ; vol.write(box, 0) ; vol.write_chunk(index, 0)
351
+ # a scalar fills the box, converted to the dtype only when its value survives (Behaviour 13)
352
+ vol.source_translation # the stored level's translation in `units`, beside source_voxel_size, under resample and relabel
353
+ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys of `attrs` (Behaviour 11)
354
+ ```
355
+
340
356
  ## 3. Conventions
341
357
 
342
358
  These are the rules the package exists to hold in one place. They match chunkmirage's
@@ -682,9 +698,11 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
682
698
  compression; cellmap-flow's blosc stays blosc by passing it. There is no overwrite: with
683
699
  `exists="error"` any document already at `path` raises `FileExistsError`; with
684
700
  `exists="adopt"` an array whose shape, dtype, chunks, shards and placement match
685
- (placement within `rtol 1e-6`) is opened for writing and its dataset entry ensured, and
686
- one that differs raises `ValueError` listing every differing field with the value found
687
- and the value wanted (cellmap-flow's `_refuse_another_region`, registration-station's
701
+ (placement within `rtol 1e-6`), and whose attributes hold every key of `attrs` with the
702
+ value given as JSON stores it (keys not given are someone else's and not compared), is
703
+ opened for writing and its dataset entry ensured, and one that differs raises
704
+ `ValueError` listing every differing field and key with the value found and the value
705
+ wanted (cellmap-flow's `_refuse_another_region`, registration-station's
688
706
  missing check, which let a shorter last band's chunk grid win a creation race). A
689
707
  creation that loses a race, the store reporting the array present after the probe said
690
708
  absent, is re-read with backoff, 5 attempts at `0.5 s * attempt`, and then treated as an
@@ -697,7 +715,9 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
697
715
  being the one it replaces. `create` returns a writable `WorldArray`;
698
716
  `wa.open(path, mode="r+")` opens an existing one with every rule of Conventions 3 to 5
699
717
  unchanged, and a handle pickles by its arguments and its context's spec, so each worker
700
- process opens its own cheaply, on the shared context when the handle had one, else on
718
+ process opens its own cheaply (a local path goes resolved against the working directory
719
+ at open, so a worker that changes directory before unpickling, as cellmap-analyze's dask
720
+ workers do, opens the same store; `reopen` uses it too), on the shared context when the handle had one, else on
701
721
  a context rebuilt once per process from the same spec, so a capped context stays
702
722
  capped (mesh-n-bone's per-process cache and thread caps).
703
723
 
@@ -722,7 +742,12 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
722
742
 
723
743
  13. **Aligned writes need no coordination.** `write_index(begin, end, data)` requires
724
744
  `data.shape == end - begin`, `data.dtype == vol.dtype` (no silent cast; `ValueError`),
725
- and `[begin, end)` to be a union of whole write units: every bound a multiple of
745
+ or `data` a scalar (a number or a 0-d array) that fills the box: it is converted to the
746
+ dtype only when its value survives, so an integer or bool array takes whole numbers in
747
+ its range and a float array any number it does not overflow, rounded to its precision
748
+ (`0` into `uint8` is written; `300`, `-1`, `0.5` and NaN raise), and a scalar equal to
749
+ `fill_value` over whole units leaves them absent as an all-fill array does (cellmap-analyze
750
+ writes a constant for every empty or masked-out block). The box must be a union of whole write units: every bound a multiple of
726
751
  `write_shape` on its axis or the array's end on that axis, so the clipped last unit is
727
752
  allowed; otherwise `ValueError` names the axis, the bound and the unit size. A unit
728
753
  written whole is one put, so writers in different processes writing different units
@@ -809,6 +834,22 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
809
834
  returns the OME short form for any spelling the table knows and raises for one it does
810
835
  not.
811
836
 
837
+ 20. **Reading onto a grid.** `vol.read_onto(grid, begin, end)` returns voxels `[begin, end)`
838
+ of a `Grid`'s spatial axes, all of it when `begin` and `end` are None, read from this
839
+ array, with exactly `end - begin` voxels on those axes; the array's other axes are read
840
+ as `read` reads them (whole, or pinned by `select`), the open-time `padding`, `method`,
841
+ `retries` and `transform` apply, and `mask` marks the grid voxels that held data. It is
842
+ `read(Box.from_index(begin, end, grid voxel size, grid translation), voxel_size=grid
843
+ voxel size, anchor=grid corner)`, so Behaviours 2 and 4 decide every voxel: at the
844
+ array's own voxel size (within level selection's rtol 1e-6) nothing is resampled,
845
+ whatever the offset between the grids, and each grid voxel takes the stored voxel whose
846
+ centre lies in it, never an interpolation, with `method="linear"` too; at any other
847
+ voxel size the array is resampled onto the grid, anchored at its corner. Adjoining
848
+ `[begin, end)` reads therefore agree with one read across both. The grid's spatial axes
849
+ must be named as the array's are (`ValueError` otherwise), and its units are converted
850
+ to the array's `units`. This is cellmap-analyze's per-operation grid: blocks come from
851
+ one `Grid`, every input is read onto it, and outputs are created on it.
852
+
812
853
  ## 5. Tests (behavioural; synthetic stores in `tmp_path`; one optional network test)
813
854
 
814
855
  Conformance, one tiny store each, checking `voxel_size`, `translation`, `axes`,
@@ -898,8 +939,10 @@ factor; missing ancestor documents down from the `.zarr` root and none above it,
898
939
  ancestor's format followed and a contradiction refused, an array in the way refused; an
899
940
  adopt writing no document and a changed placement writing one; a lost entry written again
900
941
  and the error after the attempts; `ensure_group_entry` on a foreign array, idempotent; a
901
- pickled handle keeping a capped context's spec and sharing the shared one; `codecs_of` by
902
- path; an all-fill write removing its own key.
942
+ pickled handle keeping a capped context's spec and sharing the shared one, and a relative
943
+ path reopening after a `chdir`; `codecs_of` by path; an all-fill write removing its own key;
944
+ a scalar filling a box through `write_index`, `write` and `write_chunk`, refused when it
945
+ does not fit the dtype.
903
946
 
904
947
  Grids (0.3): `blocks` partitioning with and without a box, `context` as an int, per axis
905
948
  and as before/after pairs, clipping on and off, `len`, `blocks[id]` and `index_of`;
@@ -908,6 +951,14 @@ and as before/after pairs, clipping on and off, `len`, `blocks[id]` and `index_o
908
951
  reproducing mesh-n-bone's anisotropic table (8, 8, 30 nm: z stays at 1 until y and x
909
952
  reach 32) and all 2s for isotropic data; `abbreviation` for every spelling in the table.
910
953
 
954
+ Reading onto a grid (0.4): `read_onto` at the array's voxel size returning stored values
955
+ under a sub-voxel offset with `method="linear"`, and the next voxel past a half-voxel one;
956
+ onto a finer grid equal to the `read` it is defined as, in micrometres too, adjoining
957
+ reads agreeing, the mask marking grid voxels before the data; its own grid at voxel sizes
958
+ 3.54, 4 and 5.3 returning the stored array exactly; a grid with other axis names refused.
959
+ `source_translation` under relabel and resample and in micrometres; an adopt keeping keys
960
+ it was not given and refusing a different or missing one, naming both values.
961
+
911
962
  Target: about 600 lines of test cases for 0.1 and about 300 more for 0.2 and 0.3, the
912
963
  store builders in `conftest.py` aside; no wording pins; no `/nrs`; no cluster.
913
964
 
@@ -1084,10 +1135,14 @@ tests, minimal reusable code. The README is sections 1 to 5 of this file; a beha
1084
1135
  is not stated here is not implemented without adding the statement first.
1085
1136
 
1086
1137
  Versioning. Every consumer pins a floating range and its CI installs the newest version in
1087
- it, so a change in where any voxel sits is a **minor** release, never a patch: 0.1.1 moved
1088
- offset-less N5 pyramids by half a base voxel as a patch, and cellmap-flow's CI was red on
1089
- every open PR within the hour until it pinned 0.1.1. A patch release reads more stores or
1090
- reads them faster and places nothing differently; a minor release may move voxels or add
1091
- API, and its changelog entry starts with **Placement change** when it does; nothing before
1092
- 1.0 promises more. Consumers that cannot absorb a move between CI runs pin
1093
- `>=0.x,<0.(x+1)`.
1138
+ it within the hour of a release, so the version number says what a release can break. A
1139
+ **patch** release brings the code back to what this spec says, or reads more stores or
1140
+ reads them faster; it places nothing differently and adds nothing public. A **minor**
1141
+ release is anything else: it may move voxels or add API, and an entry added to, renamed in
1142
+ or removed from the unit table is API (`wa.units.TABLE` is public, and cellmap-flow exports
1143
+ it to its browser app behind a freshness test). Both were once shipped as patches and both
1144
+ turned cellmap-flow's CI red on every open PR within the hour: 0.1.1 moved offset-less N5
1145
+ pyramids by half a base voxel, and 0.3.1 added kilometres. A changelog entry starts with
1146
+ **Placement change** when voxels move and **Unit table change** when the table does, and the
1147
+ release message to each consumer repeats those lines; nothing before 1.0 promises more.
1148
+ Consumers that cannot absorb a move between CI runs pin `>=0.x,<0.(x+1)`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.3.1"
3
+ version = "0.4.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" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.3.1"
31
+ __version__ = "0.4.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -36,7 +36,15 @@ from .placement import (
36
36
  read_meta_light,
37
37
  )
38
38
  from .retry import Retry, read_with_retries
39
- from .store import Node, detect, is_remote, join, open_array, precomputed_scale_index
39
+ from .store import (
40
+ Node,
41
+ detect,
42
+ is_remote,
43
+ join,
44
+ normalize_path,
45
+ open_array,
46
+ precomputed_scale_index,
47
+ )
40
48
 
41
49
  logger = logging.getLogger(__name__)
42
50
 
@@ -90,6 +98,9 @@ class WorldArray:
90
98
  if mode not in ("r", "r+"):
91
99
  raise ValueError("mode is 'r' (read) or 'r+' (read and write)")
92
100
  self.path = path
101
+ # where later opens find it (reopen, a pickle, the pyramid): a local path resolved
102
+ # against the working directory now, so a worker that changes directory opens the same store
103
+ self._where = normalize_path(path)
93
104
  self._mode = mode
94
105
  self._store_fill = bool(store_fill)
95
106
  self.padding = padding
@@ -139,8 +150,9 @@ class WorldArray:
139
150
  self._meta = self._rescaled_to_base(base_voxel_size, base_units)
140
151
  space, time = _unit_targets(units)
141
152
  self._grid = self._meta.in_units(space=space, time=time, strict=strict_units)
142
- # the stored level's spatial voxel size, before any relabel renames the grid
153
+ # the stored level's spatial placement, before any relabel renames the grid
143
154
  self._stored_voxel_size = tuple(self._grid.voxel_size[i] for i in self._grid.spatial)
155
+ self._stored_translation = tuple(self._grid.translation[i] for i in self._grid.spatial)
144
156
 
145
157
  self._target: tuple[float, ...] | None = None
146
158
  if voxel_size is not None:
@@ -305,6 +317,13 @@ class WorldArray:
305
317
  is, whether reads resample it or ``on_mismatch="relabel"`` renames its grid."""
306
318
  return self._stored_voxel_size
307
319
 
320
+ @property
321
+ def source_translation(self) -> tuple[float, ...]:
322
+ """The stored level's translation (the centre of its voxel 0) on the spatial axes, in
323
+ ``units``: where the level is, whether reads resample it or ``on_mismatch="relabel"``
324
+ renames its grid."""
325
+ return self._stored_translation
326
+
308
327
  @property
309
328
  def resampled(self) -> bool:
310
329
  """Whether reads resample to a target voxel size (``on_mismatch="resample"``)."""
@@ -373,7 +392,7 @@ class WorldArray:
373
392
  def levels_pyramid(self) -> Pyramid:
374
393
  if self._pyramid is None:
375
394
  self._pyramid = Pyramid(
376
- self.path, context=self.context, units=self.units, select=self._select
395
+ self._where, context=self.context, units=self.units, select=self._select
377
396
  )
378
397
  return self._pyramid
379
398
 
@@ -422,14 +441,15 @@ class WorldArray:
422
441
  **overrides,
423
442
  }
424
443
  return WorldArray(
425
- self.path, context=context if context is not None else self.context, **opts
444
+ self._where, context=context if context is not None else self.context, **opts
426
445
  )
427
446
 
428
447
  def __reduce__(self):
429
448
  """A handle pickles by its arguments and its context's recipe, and reopens in the
430
449
  receiving process (Behaviour 11): a worker opens its own store, cheaply, on a shared
431
450
  context when the handle had one, else on a context rebuilt from the same spec, so a
432
- capped context stays capped."""
451
+ capped context stays capped. A local path goes as resolved at open, so a worker that
452
+ changed directory still finds the store."""
433
453
  opts = {
434
454
  **self._opts,
435
455
  "padding": self.padding,
@@ -439,7 +459,7 @@ class WorldArray:
439
459
  "retries": self.retries,
440
460
  "transform": self.transform,
441
461
  }
442
- return (_reopen, (self.path, opts, recipe_of(self.context)))
462
+ return (_reopen, (self._where, opts, recipe_of(self.context)))
443
463
 
444
464
  # --- index space ------------------------------------------------------------
445
465
 
@@ -520,7 +540,9 @@ class WorldArray:
520
540
  or the array's end), so writers in different processes need no coordination; a
521
541
  unit written whole is one put. ``partial=True`` lifts the check and does tensorstore's
522
542
  read-modify-write of the touched units, unsafe while anything else writes the same
523
- unit. No cast, no padding, no resampling; the write commits before returning."""
543
+ unit. ``data`` may be a scalar, which fills the box: it is converted to the array's
544
+ dtype only when the value survives (``0`` into ``uint8``, not ``300`` or ``0.5``).
545
+ No other cast, no padding, no resampling; the write commits before returning."""
524
546
  if not self.writable:
525
547
  raise ValueError(
526
548
  f"{self.path} was opened read-only; open it with mode='r+' or create it"
@@ -535,9 +557,11 @@ class WorldArray:
535
557
  if len(begin) != len(shape) or len(end) != len(shape):
536
558
  raise ValueError(f"{self.path}: begin and end need one entry per axis of {shape}")
537
559
  wanted = tuple(e - b for b, e in zip(begin, end))
538
- if data.shape != wanted:
560
+ if data.ndim == 0:
561
+ data = self._scalar(data)
562
+ elif data.shape != wanted:
539
563
  raise ValueError(f"{self.path}: data has shape {data.shape}; the box is {wanted}")
540
- if data.dtype != self.dtype:
564
+ elif data.dtype != self.dtype:
541
565
  raise ValueError(
542
566
  f"{self.path}: data is {data.dtype}, the array {self.dtype}; nothing is cast on write"
543
567
  )
@@ -555,6 +579,22 @@ class WorldArray:
555
579
  )
556
580
  self._store[tuple(slice(b, e) for b, e in zip(begin, end))].write(data).result()
557
581
 
582
+ def _scalar(self, value: np.ndarray) -> np.ndarray:
583
+ """A 0-d ``value`` in the array's dtype, or ``ValueError`` when converting changes it:
584
+ an integer or bool array takes only whole numbers in its range, a float array any
585
+ number that does not overflow it (rounded to its precision)."""
586
+ dtype = self.dtype
587
+ with np.errstate(invalid="ignore", over="ignore"), warnings.catch_warnings():
588
+ warnings.simplefilter("ignore")
589
+ out = value.astype(dtype)
590
+ if dtype.kind in "biu":
591
+ kept = bool(out == value)
592
+ else:
593
+ kept = bool(np.isfinite(out) or not np.isfinite(value))
594
+ if not kept:
595
+ raise ValueError(f"{self.path}: {value.item()!r} does not fit {dtype}")
596
+ return out
597
+
558
598
  def write(self, box, data: np.ndarray, partial: bool = False) -> None:
559
599
  """Write ``data`` over a world box: the voxels Behaviour 2's centre rule selects, then
560
600
  ``write_index``. A box reaching outside the array raises; nothing is padded or
@@ -880,6 +920,57 @@ class WorldArray:
880
920
  data[~inside] = padding
881
921
  return _squeeze((data, inside) if mask else data, drop)
882
922
 
923
+ def read_onto(
924
+ self,
925
+ grid,
926
+ begin: Sequence[int] | None = None,
927
+ end: Sequence[int] | None = None,
928
+ padding: Any = None,
929
+ method: str | None = None,
930
+ mask: bool = False,
931
+ retries: Retry | None = None,
932
+ transform: Transform | None = None,
933
+ post_transform: Transform | None = None,
934
+ select: dict[str, int] | None = None,
935
+ ) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
936
+ """Voxels ``[begin, end)`` of another grid (a ``Grid``), read from this array
937
+ (Behaviour 20): ``begin`` and ``end`` index the grid's spatial axes, all of it when
938
+ None, and the result has exactly that shape there; other axes are read as ``read``
939
+ reads them. At the array's own voxel size nothing is resampled, however the grids are
940
+ offset: each grid voxel takes the stored voxel whose centre lies in it. Otherwise the
941
+ array is resampled onto the grid by ``method``, anchored at the grid's corner. The
942
+ grid's units are converted to ``units``; its spatial axes must be named as this
943
+ array's are."""
944
+ sp, mine = grid.spatial, self._grid.spatial
945
+ names = tuple(grid.axes[i] for i in sp)
946
+ if names != tuple(self._grid.axes[i] for i in mine):
947
+ raise ValueError(
948
+ f"{self.path}: the grid's spatial axes are {names}; the array's "
949
+ f"{tuple(self._grid.axes[i] for i in mine)}"
950
+ )
951
+ vs, tr = [], []
952
+ for i, j in zip(sp, mine):
953
+ have, want = grid.units[i], self._grid.units[j]
954
+ f = 1.0 if have == want else U.factor(have, want)
955
+ vs.append(grid.voxel_size[i] * f)
956
+ tr.append(grid.translation[i] * f)
957
+ begin = (0,) * len(sp) if begin is None else tuple(int(b) for b in begin)
958
+ end = tuple(grid.shape[i] for i in sp) if end is None else tuple(int(e) for e in end)
959
+ if len(begin) != len(sp) or len(end) != len(sp):
960
+ raise ValueError(f"begin and end need one entry per spatial axis of the grid {names}")
961
+ return self.read(
962
+ Box.from_index(begin, end, vs, tr),
963
+ voxel_size=vs,
964
+ anchor=tuple(t - v / 2 for t, v in zip(tr, vs)),
965
+ padding=padding,
966
+ method=method,
967
+ mask=mask,
968
+ retries=retries,
969
+ transform=transform,
970
+ post_transform=post_transform,
971
+ select=select,
972
+ )
973
+
883
974
 
884
975
  def _squeeze(out, drop: tuple[int, ...]):
885
976
  if not drop:
@@ -1123,8 +1214,8 @@ def create(
1123
1214
  0.5) following the group's format (Behaviours 11 and 12). ``chunks`` is required.
1124
1215
  Nothing is ever deleted or overwritten: with ``exists="error"`` anything at ``path``
1125
1216
  raises ``FileExistsError``; with ``"adopt"`` an identical array (shape, dtype, chunks,
1126
- shards, placement) is opened for writing and a different one raises ``ValueError``
1127
- naming every difference. Returns a writable ``WorldArray``."""
1217
+ shards, placement, and every key of ``attrs`` already holding the value given) is opened
1218
+ for writing and a different one raises ``ValueError`` naming every difference. Returns a writable ``WorldArray``."""
1128
1219
  if grid is not None:
1129
1220
  # a Grid planned the output (Behaviour 18): its facts are the array's, chunks and
1130
1221
  # shards overridable; the placement arguments must not also be given
@@ -1165,6 +1256,7 @@ def create(
1165
1256
  shards=shards,
1166
1257
  voxel_size=vs,
1167
1258
  translation=tr,
1259
+ attrs=attrs,
1168
1260
  )
1169
1261
  if diffs:
1170
1262
  raise ValueError(
@@ -803,9 +803,12 @@ def differences(
803
803
  shards: Sequence[int] | None,
804
804
  voxel_size: Sequence[float],
805
805
  translation: Sequence[float],
806
+ attrs: dict | None = None,
806
807
  ) -> list[str]:
807
808
  """How an existing array's ``ArrayMeta`` differs from what a caller wanted to create:
808
- one line per field, empty when identical (placement within ``RTOL``)."""
809
+ one line per field, empty when identical (placement within ``RTOL``). Of the array's
810
+ attributes only the keys of ``attrs`` are compared, as JSON stores them; every other key
811
+ is someone else's."""
809
812
  out = []
810
813
  if tuple(meta.shape) != tuple(int(n) for n in shape):
811
814
  out.append(f"shape: found {tuple(meta.shape)}, wanted {tuple(shape)}")
@@ -826,4 +829,10 @@ def differences(
826
829
  out.append(
827
830
  f"{what}: found {tuple(float(x) for x in f)}, wanted {tuple(float(x) for x in w)}"
828
831
  )
832
+ found_attrs = meta.attrs or {}
833
+ for key, wanted in json.loads(json.dumps(attrs or {})).items():
834
+ if key not in found_attrs:
835
+ out.append(f"attrs[{key!r}]: missing, wanted {wanted!r}")
836
+ elif found_attrs[key] != wanted:
837
+ out.append(f"attrs[{key!r}]: found {found_attrs[key]!r}, wanted {wanted!r}")
829
838
  return out
@@ -554,6 +554,12 @@ def test_relabel_rescales_everything_by_the_ratio(tmp_path):
554
554
  resampled = wa.open(str(group / "s0"), voxel_size=(8, 8, 8), on_mismatch="resample")
555
555
  assert resampled.source_voxel_size == (4.0, 4.0, 4.0)
556
556
  assert resampled.voxel_size == (8.0, 8.0, 8.0)
557
+ for v in (
558
+ vol,
559
+ resampled,
560
+ ): # where the level is, beside what it is (cellmap-flow's stored_placement)
561
+ assert v.source_translation == (10.0, 10.0, 10.0)
562
+ assert wa.open(str(group / "s0"), units="micrometer").source_translation == (0.01, 0.01, 0.01)
557
563
 
558
564
 
559
565
  def test_a_resampled_box_is_the_target_voxels_shape_counts(tmp_path):
@@ -139,3 +139,45 @@ def test_partial_block_mean_and_edge_fill(tmp_path):
139
139
  ((0, 0, 0), (48, 16, 16)), voxel_size=(8, 8, 8), method="nearest", padding="edge"
140
140
  )
141
141
  assert list(near[:, 0, 0]) == pytest.approx([1, 3, 5, 6, 6, 6])
142
+
143
+
144
+ def test_read_onto_reads_another_grids_voxels(tmp_path):
145
+ """cellmap-analyze's per-operation grid (Behaviour 20): voxels [begin, end) of a grid,
146
+ exactly that many, from any array; at the array's own voxel size nothing is resampled,
147
+ however far under a voxel the grids are offset."""
148
+ path, data = _ramp(tmp_path, "float32")
149
+ vol = wa.open(path, method="linear")
150
+ near = wa.Grid((8, 4, 4), (4, 4, 4), (3, 3, 3)) # corner 1 nm past the array's
151
+ np.testing.assert_array_equal(vol.read_onto(near), data) # stored values, not interpolated
152
+ far = wa.Grid(
153
+ (8, 4, 4), (4, 4, 4), (5, 2, 2)
154
+ ) # 3 nm past on z: each grid voxel holds the next centre
155
+ out = vol.read_onto(far)
156
+ assert out.shape == (8, 4, 4)
157
+ np.testing.assert_array_equal(out[:7], data[1:])
158
+ assert np.all(out[7] == 0) # the last grid voxel holds no stored centre: the fill
159
+ finer = wa.Grid((16, 8, 8), (2, 2, 2), (-1, -1, -1), chunks=(4, 4, 4)) # corner -2
160
+ expect = vol.read(finer.box, voxel_size=(2, 2, 2), anchor=finer.corner)
161
+ np.testing.assert_array_equal(vol.read_onto(finer), expect)
162
+ halves = np.concatenate(
163
+ [vol.read_onto(finer, (0, 0, 0), (6, 8, 8)), vol.read_onto(finer, (6, 0, 0), (16, 8, 8))]
164
+ )
165
+ np.testing.assert_array_equal(halves, expect) # adjoining reads agree with one read
166
+ in_um = wa.Grid((16, 8, 8), (0.002,) * 3, (-0.001,) * 3, units="micrometer")
167
+ np.testing.assert_array_equal(vol.read_onto(in_um), expect)
168
+ # the grid's voxels -1 and 0 are centred at -3 and -1 nm, before the data: fill, masked
169
+ out, inside = vol.read_onto(finer, (-1, 1, 1), (3, 3, 3), mask=True)
170
+ assert out.shape == (4, 2, 2) and not inside[:2].any() and inside[2:].all()
171
+ with pytest.raises(ValueError, match="spatial axes"):
172
+ vol.read_onto(wa.Grid((4, 4), (4, 4), (2, 2), axes=("y", "x")))
173
+
174
+
175
+ def test_read_onto_its_own_grid_is_the_stored_array(tmp_path):
176
+ data = np.arange(7 * 6 * 5, dtype="uint16").reshape(7, 6, 5)
177
+ group = tmp_path / "odd.zarr"
178
+ write_zarr2_group(group, ome_multiscales("zyx", [("s0", (3.54, 4, 5.3), (1.0, -2.3, 7.7))]))
179
+ write_zarr2(group / "s0", data, chunks=(3, 3, 3))
180
+ vol = wa.open(str(group / "s0"))
181
+ np.testing.assert_array_equal(vol.read_onto(wa.Grid.from_meta(vol.grid)), data)
182
+ twice = wa.Grid.from_meta(vol.grid, voxel_size=(1.77, 2, 2.65))
183
+ assert vol.read_onto(twice).shape == twice.shape == (14, 12, 10)
@@ -272,6 +272,19 @@ def test_create_never_overwrites_and_adopts_only_an_identical_array(tmp_path):
272
272
  wa.create(path, *args[:3], (3, 2, 2), chunks=(4, 4, 4), exists="adopt")
273
273
 
274
274
 
275
+ def test_adopt_checks_the_attributes_it_was_given(tmp_path):
276
+ """cellmap-flow's _refuse_another_region: funlib's prepare_ds kept an old offset silently."""
277
+ path = str(tmp_path / "vol.zarr/s0")
278
+ args = ((8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2))
279
+ wa.create(path, *args, chunks=(4, 4, 4), attrs={"offset": (0, 0, 0), "model": "a"})
280
+ again = wa.create(path, *args, chunks=(4, 4, 4), attrs={"offset": [0.0, 0, 0]}, exists="adopt")
281
+ assert again.meta.attrs["model"] == "a" # keys not given are not compared
282
+ with pytest.raises(ValueError) as e:
283
+ wa.create(path, *args, chunks=(4, 4, 4), attrs={"model": "b", "run": 2}, exists="adopt")
284
+ assert "attrs['model']: found 'a', wanted 'b'" in str(e.value)
285
+ assert "attrs['run']: missing, wanted 2" in str(e.value)
286
+
287
+
275
288
  def test_create_keeps_everyone_elses_attributes(tmp_path):
276
289
  group = tmp_path / "shared.zarr"
277
290
  write_zarr3_group(
@@ -720,6 +733,44 @@ def test_ensure_group_entry_lists_an_array_the_package_did_not_create(tmp_path,
720
733
  )
721
734
 
722
735
 
736
+ def test_a_pickled_relative_path_reopens_after_a_chdir(tmp_path, monkeypatch):
737
+ """cellmap-analyze's dask workers change directory before they unpickle."""
738
+ monkeypatch.chdir(tmp_path)
739
+ wa.create("b.zarr/seg/s0", (8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
740
+ vol = wa.open("b.zarr/seg/s0", mode="r+")
741
+ vol.write_index((0, 0, 0), (4, 4, 4), np.full((4, 4, 4), 9, np.uint8))
742
+ blob = pickle.dumps(vol)
743
+ (tmp_path / "elsewhere").mkdir()
744
+ monkeypatch.chdir(tmp_path / "elsewhere")
745
+ again = pickle.loads(blob)
746
+ assert again.path == str(tmp_path / "b.zarr/seg/s0")
747
+ assert int(again.read_index((0, 0, 0), (1, 1, 1))[0, 0, 0]) == 9
748
+ assert vol.reopen().level_path == again.level_path
749
+
750
+
751
+ def test_a_scalar_fills_the_box(tmp_path):
752
+ vol = wa.create(
753
+ str(tmp_path / "vol.zarr/s0"), (8, 8, 8), "uint8", (1, 1, 1), (0.5,) * 3, chunks=(4, 4, 4)
754
+ )
755
+ vol.write_index((0, 0, 0), (4, 8, 8), 7)
756
+ vol.write(((4, 0, 0), (8, 4, 4)), np.uint8(3))
757
+ vol.write_chunk((1, 1, 1), 5)
758
+ got = vol.read_index((0, 0, 0), (8, 8, 8))
759
+ assert np.all(got[:4] == 7) and np.all(got[4:, :4, :4] == 3) and np.all(got[4:, 4:, 4:] == 5)
760
+ for bad in (300, -1, 0.5, float("nan"), True + 255):
761
+ with pytest.raises(ValueError, match="does not fit uint8"):
762
+ vol.write_index((0, 0, 0), (4, 4, 4), bad)
763
+ vol.write_index((0, 0, 0), (4, 4, 4), 0) # the fill over a whole unit leaves it absent
764
+ assert (0, 0, 0) not in vol.list_chunks() and (0, 1, 0) in vol.list_chunks()
765
+ f = wa.create(
766
+ str(tmp_path / "f.zarr/s0"), (4,), "float32", (1,), (0.5,), axes=("x",), chunks=(4,)
767
+ )
768
+ f.write_index((0,), (4,), 0.1) # rounded to the array's precision
769
+ assert np.all(f.read_index((0,), (4,)) == np.float32(0.1))
770
+ with pytest.raises(ValueError, match="does not fit float32"):
771
+ f.write_index((0,), (4,), 1e300)
772
+
773
+
723
774
  def test_a_pickled_handle_keeps_its_context(tmp_path):
724
775
  path = str(tmp_path / "vol.zarr/s0")
725
776
  wa.create(path, (8, 8, 8), "uint16", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.3.0"
1250
+ version = "0.4.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