worldarray 0.1.1__tar.gz → 0.2.0__tar.gz

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