worldarray 0.1.0__tar.gz → 0.1.1__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 (32) hide show
  1. worldarray-0.1.1/CHANGELOG.md +35 -0
  2. {worldarray-0.1.0 → worldarray-0.1.1}/PKG-INFO +3 -5
  3. {worldarray-0.1.0 → worldarray-0.1.1}/README.md +2 -4
  4. {worldarray-0.1.0 → worldarray-0.1.1}/docs/spec.md +39 -22
  5. {worldarray-0.1.0 → worldarray-0.1.1}/pyproject.toml +1 -1
  6. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/array.py +16 -9
  8. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/levels.py +12 -4
  9. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/placement.py +40 -15
  10. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/store.py +16 -2
  11. {worldarray-0.1.0 → worldarray-0.1.1}/tests/fixtures/metadata.json +8 -8
  12. {worldarray-0.1.0 → worldarray-0.1.1}/tests/test_access.py +16 -0
  13. {worldarray-0.1.0 → worldarray-0.1.1}/tests/test_metadata.py +33 -0
  14. {worldarray-0.1.0 → worldarray-0.1.1}/tests/test_read.py +49 -8
  15. {worldarray-0.1.0 → worldarray-0.1.1}/uv.lock +1 -1
  16. worldarray-0.1.0/CHANGELOG.md +0 -13
  17. {worldarray-0.1.0 → worldarray-0.1.1}/.github/workflows/ci.yml +0 -0
  18. {worldarray-0.1.0 → worldarray-0.1.1}/.github/workflows/docs.yml +0 -0
  19. {worldarray-0.1.0 → worldarray-0.1.1}/.github/workflows/publish.yml +0 -0
  20. {worldarray-0.1.0 → worldarray-0.1.1}/.gitignore +0 -0
  21. {worldarray-0.1.0 → worldarray-0.1.1}/LICENSE +0 -0
  22. {worldarray-0.1.0 → worldarray-0.1.1}/docs/changelog.md +0 -0
  23. {worldarray-0.1.0 → worldarray-0.1.1}/docs/index.md +0 -0
  24. {worldarray-0.1.0 → worldarray-0.1.1}/mkdocs.yml +0 -0
  25. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/context.py +0 -0
  26. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/geometry.py +0 -0
  27. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/resample.py +0 -0
  28. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/retry.py +0 -0
  29. {worldarray-0.1.0 → worldarray-0.1.1}/src/worldarray/units.py +0 -0
  30. {worldarray-0.1.0 → worldarray-0.1.1}/tests/conftest.py +0 -0
  31. {worldarray-0.1.0 → worldarray-0.1.1}/tests/test_geometry.py +0 -0
  32. {worldarray-0.1.0 → worldarray-0.1.1}/tests/test_resample.py +0 -0
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 (2026-10-22)
4
+
5
+ - funlib `voxel_size`/`offset` on the parent group place an array that carries none, for
6
+ zarr and N5 alike; funlib's keys on an N5 array are read in the array's C order, as
7
+ zarr-python's N5 store writes them (both from mesh-n-bone's review of its PR #48).
8
+ - A precomputed path that names a scale is that scale under `level=0` or `"first"` too;
9
+ other levels warn and are ignored, as on an array path.
10
+ - The GCE metadata guard is applied only off Google Cloud (decided from the DMI product
11
+ name and the `GCE_METADATA_*` variables), so metadata-server credentials keep working on
12
+ a Google VM.
13
+ - `Pyramid.placements()`, `voxel_sizes()` and `translations()` read the group document when
14
+ the pyramid was opened through one of its level paths too; they used to open every level
15
+ from there, and a missing level made them raise.
16
+ - **Placement change.** An N5 level with `downsamplingFactors` and no offset now sits with
17
+ its corner at the origin at every level, as Neuroglancer draws N5: translation
18
+ `f / 2 * base`. 0.1.0 followed BigDataViewer for `pixelResolution` pyramids, centring the
19
+ base's voxel 0 on the origin (`(f - 1) / 2 * base`, half a base voxel lower), while
20
+ placing `resolution` pyramids with the corner at the origin, so the two disagreed. Two
21
+ shared fixture cases move and are renamed: `n5_group_pixelresolution_and_downsampling_factors_corner_at_origin`
22
+ (translation 4, 4, 4) and `n5_bigstitcher_resolution_with_downsampling_factors_corner_at_origin`
23
+ (0.5, 0.157, 0.157).
24
+
25
+ ## 0.1.0 (2026-10-21)
26
+
27
+ - First version, built to `docs/spec.md`: open zarr v2, zarr v3, N5 and Neuroglancer
28
+ precomputed arrays and pyramids through tensorstore; one metadata precedence per format
29
+ with the voxel-centre rule for translations; index-space and world-box reads with
30
+ padding; level selection; nearest and linear resampling; retries; shared and capped
31
+ tensorstore contexts.
32
+ Built and trialled in one day against the readers it replaces in cellmap-flow,
33
+ cellmap-analyze, mesh-n-bone and chunkmirage, and checked on their own data by the
34
+ CineMap, liconn-err, registration-station and salamander repos; see `docs/spec.md`,
35
+ "Trial status". Hot-path reads cost 0.1 to 0.2 ms over a raw tensorstore read.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
5
5
  Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
6
6
  Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
@@ -67,8 +67,7 @@ pip install worldarray
67
67
 
68
68
  ## Why
69
69
 
70
- Volumetric data at Janelia lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed
71
- stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
70
+ Volumetric data lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
72
71
  its voxel size, origin, units and axes, in its own dialect, and the dialects disagree on
73
72
  the one thing that matters most: whether a stored origin means the centre of the first
74
73
  voxel or its corner. Get that wrong and every object is half a voxel out of place, which
@@ -79,8 +78,7 @@ rule for each, picking a pyramid level for a wanted resolution, and reading a re
79
78
  given in physical units as a plain numpy block, padded where the region leaves the data,
80
79
  resampled when no level matches, fast enough for a model server or a thousand cluster
81
80
  workers, with retries for flaky object stores and thread caps for shared hosts. No one
82
- package did all of that, so every tool here wrote its own reader, and the readers drifted
83
- apart. worldarray is that reader, written once, with the rules stated and tested.
81
+ package did all of that, so every tool wrote its own reader, and the readers drifted apart. worldarray is that reader, written once, with the rules stated and tested.
84
82
 
85
83
  Its dependencies are `tensorstore` and `numpy`. It is read-only in this version. The
86
84
  [specification](https://janeliascicomp.github.io/worldarray/spec/) is the contract it is
@@ -8,8 +8,7 @@ pip install worldarray
8
8
 
9
9
  ## Why
10
10
 
11
- Volumetric data at Janelia lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed
12
- stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
11
+ Volumetric data lives in zarr v2, zarr v3, N5 and Neuroglancer precomputed stores, on local disks, S3, GCS and HTTP. Each format records *where the array sits*,
13
12
  its voxel size, origin, units and axes, in its own dialect, and the dialects disagree on
14
13
  the one thing that matters most: whether a stored origin means the centre of the first
15
14
  voxel or its corner. Get that wrong and every object is half a voxel out of place, which
@@ -20,8 +19,7 @@ rule for each, picking a pyramid level for a wanted resolution, and reading a re
20
19
  given in physical units as a plain numpy block, padded where the region leaves the data,
21
20
  resampled when no level matches, fast enough for a model server or a thousand cluster
22
21
  workers, with retries for flaky object stores and thread caps for shared hosts. No one
23
- package did all of that, so every tool here wrote its own reader, and the readers drifted
24
- apart. worldarray is that reader, written once, with the rules stated and tested.
22
+ package did all of that, so every tool wrote its own reader, and the readers drifted apart. worldarray is that reader, written once, with the rules stated and tested.
25
23
 
26
24
  Its dependencies are `tensorstore` and `numpy`. It is read-only in this version. The
27
25
  [specification](https://janeliascicomp.github.io/worldarray/spec/) is the contract it is
@@ -144,7 +144,8 @@ pyr.metas() ; pyr.level_meta("s1") # ArrayMeta per level from the documents al
144
144
  # array's inner chunk), fill and placement, with no tensorstore opened
145
145
  pyr.placements() # (name, spatial voxel size, spatial translation) per level from the group's own document
146
146
  pyr.voxel_sizes() ; pyr.translations() # where it lists the levels (OME-NGFF, COSEM N5, precomputed), so
147
- # choosing or placing a level opens nothing; else by opening each level
147
+ # choosing or placing a level opens nothing, whether the pyramid was opened by its
148
+ # group path or by one of its level paths; else by opening each level
148
149
 
149
150
  wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False: from the documents alone
150
151
  wa.detect(path, missing_ok=False) -> Node | None # format, kind (array|group), path, docs, scale_key; .attrs (raw user
@@ -215,10 +216,15 @@ this document and that one differ, fix one of them.
215
216
  CF coordinates. Index `i` on an axis sits at `translation + i * voxel_size`; voxel `i`
216
217
  covers `[translation + (i - 1/2) * voxel_size, translation + (i + 1/2) * voxel_size)`.
217
218
  Formats that store voxel 0's **corner** are moved half a voxel along the spatial axes
218
- on reading. N5 levels with `pixelResolution` and `downsamplingFactors` and no offset
219
- follow BigDataViewer: level-L voxel `i` is centred where full-resolution voxels
220
- `f*i .. f*i+f-1` are, that is `(f - 1) / 2` full-resolution voxels on from voxel 0's
221
- centre (the imglib2 rule liconn-err and registration-station both use). The shift
219
+ on reading. An N5 level with `downsamplingFactors` and no offset sits as Neuroglancer
220
+ draws it, a pure scale of the base grid with every level's corner at the origin: level-L
221
+ voxel `i` covers full-resolution voxels `f*i .. f*i+f-1`, so its centre is `f*i + f/2`
222
+ full-resolution voxels from the origin and its translation `f / 2 * base`. BigDataViewer
223
+ centres voxel 0 on the origin instead, half a base voxel lower at every level; the shift
224
+ between levels, `(f - 1) / 2` base voxels, is the same in both and is the imglib2 rule
225
+ liconn-err and registration-station use. (Decided 2026-10-22, when mesh-n-bone found
226
+ that `pixelResolution` and `resolution` pyramids disagreed; 0.1.0 followed BigDataViewer
227
+ for `pixelResolution`.) The shift
222
228
  applies only along axes named `z`, `y` or `x`, or the last three when axes are unnamed;
223
229
  never along channel or time.
224
230
 
@@ -227,7 +233,7 @@ this document and that one differ, fix one of them.
227
233
  | OME-NGFF `translation` (zarr v2/v3) | centre of voxel 0 | as is |
228
234
  | COSEM `transform.translate` (N5 or zarr) | centre (fibsem-tools wrote it as the first sample's position; Neuroglancer ignores `transform` and `offset` on N5 entirely) | as is |
229
235
  | funlib `offset` with `resolution`/`voxel_size` (N5 or zarr; `offset` missing = 0) | corner | `offset + voxel_size / 2` |
230
- | N5 `pixelResolution` x `downsamplingFactors f`, no offset | BigDataViewer | `(f - 1) / 2 * pixelResolution` |
236
+ | N5 `pixelResolution` or `resolution` x `downsamplingFactors f`, no offset | corner at the origin at every level, as Neuroglancer draws N5 | `f / 2 * pixelResolution` |
231
237
  | precomputed `voxel_offset` x `resolution` | corner | `+ resolution / 2` |
232
238
  | CF coordinate array (xarray zarr) | sample position = centre | as is (chunkmirage layers this) |
233
239
  | nothing at all | Neuroglancer draws a plain array with its corner at the origin | voxels of 1, translation 0.5 |
@@ -284,16 +290,19 @@ this document and that one differ, fix one of them.
284
290
  `sequence`; ignore the multiscale-level transforms (they lead to other systems, which
285
291
  chunkmirage's `scene://` applies itself) and any other transform type. Else the array's
286
292
  own `resolution` / `voxel_size`, `offset`, `units`, `axis_names` (funlib) or `transform`
287
- (COSEM), C order; else tensorstore's `dimension_units` attribute (zarr v3). Axes from
293
+ (COSEM), C order; else the same funlib keys on the parent group when the array carries
294
+ none (mesh-n-bone's inputs); else tensorstore's `dimension_units` attribute (zarr v3). Axes from
288
295
  OME `axes`, `dimension_names` (zarr v3), `_ARRAY_DIMENSIONS` (xarray) or `axis_names`.
289
296
  - N5: the array's `transform` (COSEM; C order unless `ordering: "F"`); else the parent's
290
- `multiscales[].datasets[].transform` for this path; else `pixelResolution` (both the
297
+ `multiscales[].datasets[].transform` for this path; else funlib's `voxel_size` and
298
+ `offset` (array, else group), which zarr-python's N5 store leaves in the array's C
299
+ order, read by the zarr funlib rule above; else `pixelResolution` (both the
291
300
  `{dimensions, unit}` form and a bare list) or `resolution`, from the array else the
292
301
  group, times the level's `downsamplingFactors`; plus `offset`; axes from the `axes`
293
- attribute. These lists are x-first. Without an `offset`, a level with `downsamplingFactors` follows
294
- BigDataViewer whichever key gave the base (BigStitcher writes `resolution` and
295
- `downsamplingFactors`), and a funlib `resolution` with no `downsamplingFactors` means a
296
- corner at 0 (translation half a voxel); funlib never writes `downsamplingFactors`.
302
+ attribute. These lists are x-first. Without an `offset` the corner is at the origin at
303
+ every level whichever key gave the base (BigStitcher writes `resolution` and
304
+ `downsamplingFactors`; funlib writes `resolution` alone): translation `f / 2 * base`,
305
+ half a voxel of the level (Convention 1).
297
306
  - **Short lists.** A funlib, COSEM or N5 per-axis list shorter than the array (a 3-entry
298
307
  `voxel_size` on a `(c, z, y, x)` prediction, which funlib's `prepare_ds(num_channels=)`
299
308
  writes) covers the trailing axes; the leading axes get voxel size 1, translation 0,
@@ -326,8 +335,11 @@ this document and that one differ, fix one of them.
326
335
  that answers anything but a document (a server root's 301) ends the search for a
327
336
  multiscales entry quietly. Off Google Cloud, `GCE_METADATA_ROOT` is pointed at a name that
328
337
  fails at once before the first `gs://` open, because tensorstore's probe of the GCE
329
- metadata server otherwise stalls every open for seconds (`store.GUARD_GCE_METADATA`
330
- turns this off). A `precomputed://` prefix is accepted and
338
+ metadata server otherwise stalls every open for seconds; "off Google Cloud" is decided
339
+ with no network call, from the machine's DMI product name and the `GCE_METADATA_HOST` and
340
+ `GCE_METADATA_ROOT` variables, and on a Google machine the variable is left alone so the
341
+ credentials the real metadata server issues keep working (`store.GUARD_GCE_METADATA`
342
+ turns the guard off). A `precomputed://` prefix is accepted and
331
343
  dropped. The `.n5` or `.zarr` suffix is never used to decide the format. A zarr v2
332
344
  compressor carrying members tensorstore rejects (the `checksum` newer numcodecs writes
333
345
  for zstd) is opened without them. Access: a path with no scheme is a local file; `s3://`
@@ -380,13 +392,14 @@ this document and that one differ, fix one of them.
380
392
  3. **Level selection** (`select_level`, `Pyramid.select`, `open(voxel_size=)`) follows
381
393
  cellmap-flow's four modes plus one: `floor` (the level at that voxel size, else the
382
394
  **coarsest** level that is no coarser than the target on any axis, else the first),
383
- `finer` (as `floor`, but an error instead of the fallback to the first level, which is
384
- cellmap-analyze's rule), `exact` (that
385
- level or `ValueError` listing the levels), `nearest` (closest on a log scale summed over
395
+ `finer` (as `floor`, but an error instead of the fallback to the first level, for
396
+ callers that would rather fail than read coarser data than asked), `exact` (that level or
397
+ `ValueError` listing the levels), `nearest` (closest on a log scale summed over
386
398
  axes, finer on a tie), `resample` (the level to resample from). Voxel sizes compare as
387
399
  floats with `rtol 1e-6`, so 10.48 is not 10. A group path without `level` or
388
400
  `voxel_size` raises with the level list; a level name that does not exist raises with
389
- the list; an array path with `level` warns and ignores it. When no level has the
401
+ the list; an array path, or a precomputed path naming a scale, is that array or scale
402
+ under `level=0` or `"first"` silently and warns and ignores any other `level`. When no level has the
390
403
  requested `voxel_size`, `on_mismatch` decides: `"error"` raises with the list;
391
404
  `"resample"` reads the level `level_mode` picks and resamples (Behaviour 4); `"relabel"`
392
405
  reads that level voxel for voxel and reports `voxel_size` as the requested size with
@@ -487,7 +500,7 @@ chunkmirage derives from raw attributes carries a `layered` note beside the pack
487
500
 
488
501
  - chunkmirage's cases: N5 transposed to C order with `resolution` and `offset`; N5 COSEM
489
502
  `transform` C order; N5 group `pixelResolution` and `downsamplingFactors`
490
- (BigDataViewer); N5 group `multiscales` transform matched by path; OME levels matched by
503
+ (the corner at the origin at every level, as Neuroglancer); N5 group `multiscales` transform matched by path; OME levels matched by
491
504
  path; OME top-level transforms applied after each dataset; an OME-unlisted array borrows
492
505
  no other level's scale; zarr legacy array attrs as a corner; a zarr3 OME 0.5 group with a
493
506
  channel axis; precomputed `voxel_offset` as a centre translation; xarray dimensions and
@@ -510,7 +523,11 @@ chunkmirage derives from raw attributes carries a `layered` note beside the pack
510
523
  fallback; detection ignores the directory suffix and finds a precomputed scale key.
511
524
 
512
525
  Reads: every statement in Behaviour 1, 2, 3 and 9; a document-less group probed through
513
- `s0`, integer and `"first"` levels, `detect(missing_ok=True)`, `index_box`; `read_index`
526
+ `s0`, integer and `"first"` levels, `detect(missing_ok=True)`, `index_box`; funlib keys on
527
+ the parent group of an attribute-less zarr and N5 array, and funlib `voxel_size`/`offset` on
528
+ an N5 array in C order; a precomputed path naming a scale kept under `level="first"`; the
529
+ GCE guard left alone on a Google machine; a pyramid opened through one of its level paths
530
+ placing every level from the group document while one level's array is missing; `read_index`
514
531
  with `select`; `base_voxel_size` rescaling a BigStitcher-style pyramid; `AWS_ENDPOINT_URL`,
515
532
  `AWS_ENDPOINT_URL_S3` and the region reaching the S3 specs and the GCS redirect keying the
516
533
  kvstore cache; an array path kept under `voxel_size`; relabel keeping the corner; a
@@ -531,8 +548,8 @@ the data; a resample target set at open reports the target voxel size with the s
531
548
  Placement: the same cube stored four ways, as OME-NGFF with a `translation`, as N5 with
532
549
  funlib `resolution`/`offset`, as N5 with a COSEM `transform`, and as precomputed with a
533
550
  `voxel_offset`, reads back with the same `translation` and the same bytes for the same
534
- world box, within `0.1 * voxel_size`; and an N5 BigDataViewer level 1 sits where the
535
- equivalent OME level 1 does.
551
+ world box, within `0.1 * voxel_size`; and an N5 level 1 with `downsamplingFactors` sits where the
552
+ equivalent OME level 1 does, both pyramids' s0 corner at the origin.
536
553
 
537
554
  Network (skipped without it; not yet written): one public OpenOrganelle volume with a
538
555
  non-integer z spacing (4.56 or 5.24 nm), read anonymously from s3: the package reports the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.1.0"
3
+ version = "0.1.1"
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" }
@@ -17,7 +17,7 @@ from .placement import ArrayMeta, corner_to_centre, default_axes
17
17
  from .retry import Retry
18
18
  from .store import Node, detect, list_chunk_keys
19
19
 
20
- __version__ = "0.1.0"
20
+ __version__ = "0.1.1"
21
21
 
22
22
  __all__ = [
23
23
  "ArrayMeta",
@@ -26,7 +26,7 @@ from . import resample as _resample
26
26
  from . import units as U
27
27
  from .context import shared_context
28
28
  from .geometry import Box, index_range, is_aligned
29
- from .levels import LevelRef, Mode, discover, same_voxel_size, select_level
29
+ from .levels import LevelRef, Mode, discover, discover_group, same_voxel_size, select_level
30
30
  from .placement import ArrayMeta, light_levels, read_meta, read_meta_light
31
31
  from .retry import Retry, read_with_retries
32
32
  from .store import Node, detect, is_remote, open_array, precomputed_scale_index
@@ -145,11 +145,17 @@ class WorldArray:
145
145
  # --- construction helpers -------------------------------------------------
146
146
 
147
147
  def _choose_level(self, node: Node, level, voxel_size, level_mode, on_mismatch) -> LevelRef:
148
- if level == "first":
149
- level = 0
150
- if node.format == "precomputed" and node.scale_key is not None and level is None:
148
+ if node.format == "precomputed" and node.scale_key is not None:
149
+ # a path naming a scale is that scale, as an array path is that array
150
+ if level not in (None, 0, "first"):
151
+ warnings.warn(
152
+ f"{self.path} names a precomputed scale; level={level!r} is ignored",
153
+ stacklevel=3,
154
+ )
151
155
  index = precomputed_scale_index(node.docs["info"], node.scale_key)
152
156
  return LevelRef(node.scale_key, node.path, index)
157
+ if level == "first":
158
+ level = 0
153
159
  if node.kind == "array":
154
160
  if level is not None and level != 0: # "first" (already 0) and 0 mean "this array"
155
161
  warnings.warn(f"{self.path} is an array; level={level!r} is ignored", stacklevel=3)
@@ -743,7 +749,7 @@ class Pyramid:
743
749
  self._select = select or {}
744
750
  self._kwargs = kwargs
745
751
  self._node = _node if _node is not None else detect(path)
746
- self._refs = discover(self._node)
752
+ self._group, self._refs = discover_group(self._node) # the node listing the levels
747
753
  if not self._refs:
748
754
  raise ValueError(
749
755
  f"{path}: no levels found (no multiscales datasets, no s0, no precomputed scales)"
@@ -795,9 +801,8 @@ class Pyramid:
795
801
  COSEM N5, precomputed), else by opening each level."""
796
802
  if self._light is None:
797
803
  space, _ = _unit_targets(self.units)
798
- group = self._node if self._node.kind == "group" else None
799
- if group is None and self._refs and self._refs[0].path != self._node.path:
800
- group = detect(self._refs[0].path.rsplit("/", 1)[0], missing_ok=True)
804
+ # the group the levels were discovered from, whichever path opened the pyramid
805
+ group = self._group
801
806
  light = light_levels(group, self.names, space) if group is not None else None
802
807
  if light is None:
803
808
  light = [
@@ -867,7 +872,9 @@ def metadata(
867
872
  if level == "first":
868
873
  level = 0
869
874
  if node.format == "precomputed" and (level is not None or node.scale_key is not None):
870
- wanted = node.scale_key if level is None else level
875
+ wanted = (
876
+ node.scale_key if node.scale_key is not None else level
877
+ ) # a scale path is that scale
871
878
  meta = read_meta_light(node, scale_index=precomputed_scale_index(node.docs["info"], wanted))
872
879
  elif node.kind == "group":
873
880
  refs = discover(node)
@@ -48,8 +48,16 @@ def _probe_levels(path: str) -> list[str]:
48
48
  def discover(node: Node) -> list[LevelRef]:
49
49
  """The levels of a group node, or of the group an array node belongs to (just the array
50
50
  itself when its parent is not a multiscale group)."""
51
+ return discover_group(node)[1]
52
+
53
+
54
+ def discover_group(node: Node) -> tuple[Node | None, list[LevelRef]]:
55
+ """``discover`` plus the node whose document lists those levels: the group itself, the
56
+ ancestor an array path belongs to, the precomputed ``info`` node; None for an array
57
+ standing alone, so a ``Pyramid`` opened through a level path still reads placements
58
+ from the group document instead of opening every level."""
51
59
  if node.format == "precomputed":
52
- return [
60
+ return node, [
53
61
  LevelRef(str(s.get("key", i)), node.path, i)
54
62
  for i, s in enumerate(node.docs["info"]["scales"])
55
63
  ]
@@ -67,14 +75,14 @@ def discover(node: Node) -> list[LevelRef]:
67
75
  and group.kind == "group"
68
76
  and rel in _group_multiscale_paths(group)
69
77
  ):
70
- return discover(group)
78
+ return discover_group(group)
71
79
  if parent(ancestor) is None:
72
80
  break
73
81
  rel = f"{basename(ancestor)}/{rel}"
74
82
  ancestor = parent(ancestor)
75
- return [LevelRef(basename(node.path), node.path)]
83
+ return None, [LevelRef(basename(node.path), node.path)]
76
84
  names = _group_multiscale_paths(node) or _probe_levels(node.path)
77
- return [LevelRef(name, join(node.path, name)) for name in names]
85
+ return node, [LevelRef(name, join(node.path, name)) for name in names]
78
86
 
79
87
 
80
88
  def _spatial_voxel_size(level) -> np.ndarray:
@@ -4,8 +4,8 @@ The rule everything else rests on: a **translation is the centre of voxel 0** (O
4
4
  COSEM ``transform``, CF coordinates, Neuroglancer's own zarr reader). Formats that store
5
5
  voxel 0's corner are moved half a voxel along the spatial axes on reading: precomputed's
6
6
  ``voxel_offset`` and funlib's ``offset`` (0 when only a ``resolution`` is given). N5 levels
7
- with ``pixelResolution`` and ``downsamplingFactors`` and no offset follow BigDataViewer: a
8
- downsampled voxel's centre is the centre of the block it covers (spec, Conventions 1 and 4).
7
+ with ``downsamplingFactors`` and no offset keep their corner at the origin at every level,
8
+ as Neuroglancer draws N5: translation ``f / 2 * base`` (spec, Conventions 1 and 4).
9
9
 
10
10
  Per-axis lists shorter than the array (a 3-entry ``voxel_size`` on a ``(c, z, y, x)``
11
11
  prediction) cover the trailing axes; the leading axes get 1.0, 0.0 or ``""``. OME lists
@@ -415,6 +415,11 @@ def _zarr(
415
415
  found = _ome(hit[0], hit[1], ndim, node.path)
416
416
  if found.voxel_size is None:
417
417
  found = _array_attrs(attrs, ndim, node.path)
418
+ if found.voxel_size is None and _has_funlib_keys(group_attrs):
419
+ # funlib's keys on the parent group, the array carrying none (mesh-n-bone's inputs)
420
+ axes = found.axes
421
+ found = _array_attrs(_funlib_subset(group_attrs), ndim, node.path)
422
+ found.axes = found.axes or axes
418
423
  else:
419
424
  _check_against_attrs(attrs, found, ndim, node.path, conflicts)
420
425
  if found.axes is None:
@@ -501,6 +506,21 @@ def _unpack_sequence(transforms: list[dict]) -> list[dict]:
501
506
  return out
502
507
 
503
508
 
509
+ _FUNLIB_KEYS = ("voxel_size", "resolution", "offset", "units", "axis_names")
510
+
511
+
512
+ def _has_funlib_keys(attrs: dict) -> bool:
513
+ return any(
514
+ isinstance(attrs.get(k), (list, tuple)) for k in ("voxel_size", "resolution", "offset")
515
+ )
516
+
517
+
518
+ def _funlib_subset(attrs: dict) -> dict:
519
+ """Only funlib's keys of a group's attributes, so a group-level ``transform`` or ``scale``
520
+ (which mean other things on a group) is not taken for the array's placement."""
521
+ return {k: attrs[k] for k in _FUNLIB_KEYS if k in attrs}
522
+
523
+
504
524
  def _array_attrs(attrs: dict, ndim: int, path: str) -> _Resolved:
505
525
  """funlib (``resolution``/``voxel_size``, ``offset``, ``units``, ``axis_names``) or COSEM
506
526
  (``transform``) attributes on the array itself, C order. An ``offset`` with no voxel
@@ -581,12 +601,12 @@ def _check_against_attrs(
581
601
 
582
602
  def _n5(attrs: dict, group_attrs: dict, node: Node, ndim: int) -> _Resolved:
583
603
  """COSEM ``transform`` on the array; else the group's ``multiscales[].datasets[].transform``
584
- for this path; else ``pixelResolution``/``resolution`` (array, else group) times the
585
- level's ``downsamplingFactors``, plus ``offset``. Lists other than ``transform`` are
586
- x-first and reversed to C order here. Without an ``offset``: a level with ``downsamplingFactors`` follows
587
- BigDataViewer, ``(f - 1) / 2`` full-resolution voxels on, whichever key gave the base
588
- (BigStitcher writes ``resolution``); a funlib ``resolution`` with no
589
- ``downsamplingFactors`` means a corner at 0 (translation half a voxel)."""
604
+ for this path; else funlib's ``voxel_size``/``offset`` (array, else group), C order; else
605
+ ``pixelResolution``/``resolution`` (array, else group) times the level's
606
+ ``downsamplingFactors``, plus ``offset``. Lists other than ``transform`` and funlib's are
607
+ x-first and reversed to C order here. Without an ``offset`` the corner is at the origin
608
+ at every level, whichever key gave the base: translation ``f / 2 * base``, half a voxel
609
+ of the level, as Neuroglancer draws N5 (Convention 1)."""
590
610
  name = basename(node.path)
591
611
  transform = attrs.get("transform")
592
612
  if isinstance(transform, dict) and "scale" in transform:
@@ -596,6 +616,13 @@ def _n5(attrs: dict, group_attrs: dict, node: Node, ndim: int) -> _Resolved:
596
616
  return _cosem_transform(hit[1]["transform"], ndim, name)
597
617
 
598
618
  axes = _axes(list(attrs.get("axes", group_attrs.get("axes", [])) or [])[::-1], ndim)
619
+ for source in (attrs, group_attrs):
620
+ if isinstance(source.get("voxel_size"), (list, tuple)):
621
+ # funlib's own key names, which zarr-python's N5 store leaves in the array's C
622
+ # order (unlike resolution and pixelResolution, x-first); offset is a corner
623
+ found = _array_attrs(_funlib_subset(source), ndim, name)
624
+ found.axes = found.axes or axes
625
+ return found
599
626
  unit: str | None = None
600
627
  base, key = None, None
601
628
  for source in (attrs, group_attrs):
@@ -635,14 +662,12 @@ def _n5(attrs: dict, group_attrs: dict, node: Node, ndim: int) -> _Resolved:
635
662
 
636
663
  if "offset" in attrs:
637
664
  corner = _floats(list(attrs["offset"])[::-1], ndim, f"{name}: offset", pad=0.0)
638
- translation = corner_to_centre(corner, voxel_size, axes)
639
- elif key == "resolution" and "downsamplingFactors" not in attrs:
640
- # funlib/daisy N5: a resolution with no offset is a corner at 0 (funlib never writes
641
- # downsamplingFactors; BigStitcher writes "resolution" AND downsamplingFactors, and means BDV)
642
- translation = corner_to_centre((0.0,) * ndim, voxel_size, axes)
643
665
  else:
644
- # BigDataViewer: the downsampled voxel's centre is the centre of the block it covers
645
- translation = tuple(float(v) for v in (factors - 1) / 2 * base)
666
+ # no stored origin: the corner at the origin at every level, as Neuroglancer draws N5
667
+ # (a pure scale of the base grid by downsamplingFactors). BigDataViewer centres voxel
668
+ # 0 on the origin instead, half a base voxel lower; the shift between levels is the same
669
+ corner = (0.0,) * ndim
670
+ translation = corner_to_centre(corner, voxel_size, axes)
646
671
  return _Resolved(axes, units, tuple(float(v) for v in voxel_size), tuple(translation), "n5")
647
672
 
648
673
 
@@ -88,6 +88,19 @@ def join(base: str, *parts: str) -> str:
88
88
  # Access
89
89
 
90
90
 
91
+ @functools.lru_cache(maxsize=1)
92
+ def _on_google_cloud() -> bool:
93
+ """A Google VM or GKE node, decided with no network call: the environment names a
94
+ metadata host, or the machine's DMI product name says Google."""
95
+ if os.environ.get("GCE_METADATA_HOST") or os.environ.get("GCE_METADATA_ROOT"):
96
+ return True
97
+ try:
98
+ with open("/sys/class/dmi/id/product_name") as f:
99
+ return f.read().strip().startswith("Google")
100
+ except OSError:
101
+ return False
102
+
103
+
91
104
  def kvstore_specs(root: str) -> list[dict]:
92
105
  """The tensorstore kvstore specs that read the directory ``root``, in the order to try
93
106
  them. ``s3://``: anonymously, then AWS's default credentials (S3 refuses even a public
@@ -109,9 +122,10 @@ def kvstore_specs(root: str) -> list[dict]:
109
122
  extra["aws_region"] = region
110
123
  return [{**spec, **extra, "aws_credentials": {"type": t}} for t in ("anonymous", "default")]
111
124
  if spec["driver"] == "gcs":
112
- if GUARD_GCE_METADATA:
125
+ if GUARD_GCE_METADATA and not _on_google_cloud():
113
126
  # off Google Cloud, tensorstore's probe of the GCE metadata server for credentials
114
- # stalls every gs:// open for seconds; point it at a name that fails at once
127
+ # stalls every gs:// open for seconds; point it at a name that fails at once. On a
128
+ # Google machine the server is real and the credentials it serves must keep working
115
129
  os.environ.setdefault("GCE_METADATA_ROOT", "metadata.google.internal.invalid")
116
130
  public = (os.environ.get("TENSORSTORE_GCS_HTTP_URL") or GCS_PUBLIC_URL).rstrip("/")
117
131
  return [
@@ -104,7 +104,7 @@
104
104
  }
105
105
  },
106
106
  {
107
- "name": "n5_group_pixelresolution_and_downsampling_factors_bigdataviewer",
107
+ "name": "n5_group_pixelresolution_and_downsampling_factors_corner_at_origin",
108
108
  "format": "n5",
109
109
  "shape": [
110
110
  4,
@@ -136,9 +136,9 @@
136
136
  8
137
137
  ],
138
138
  "translation": [
139
- 2,
140
- 2,
141
- 2
139
+ 4,
140
+ 4,
141
+ 4
142
142
  ],
143
143
  "axes": [
144
144
  "z",
@@ -1289,7 +1289,7 @@
1289
1289
  }
1290
1290
  },
1291
1291
  {
1292
- "name": "n5_bigstitcher_resolution_with_downsampling_factors_is_bigdataviewer",
1292
+ "name": "n5_bigstitcher_resolution_with_downsampling_factors_corner_at_origin",
1293
1293
  "format": "n5",
1294
1294
  "shape": [
1295
1295
  4,
@@ -1319,9 +1319,9 @@
1319
1319
  0.314
1320
1320
  ],
1321
1321
  "translation": [
1322
- 0,
1323
- 0.0785,
1324
- 0.0785
1322
+ 0.5,
1323
+ 0.157,
1324
+ 0.157
1325
1325
  ],
1326
1326
  "axes": [
1327
1327
  "z",
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+
5
7
  import numpy as np
6
8
  import pytest
7
9
 
@@ -192,3 +194,17 @@ def test_capped_context_can_leave_the_network_alone():
192
194
  spec = wa.capped_context_spec(concurrency=2, network=False)
193
195
  assert "s3_request_concurrency" not in spec and spec["file_io_concurrency"] == {"limit": 2}
194
196
  assert "s3_request_concurrency" in wa.capped_context_spec()
197
+
198
+
199
+ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
200
+ """Off Google Cloud the metadata probe is short-circuited; on a Google machine the
201
+ variable is left alone so the real metadata server's credentials keep working."""
202
+ monkeypatch.setenv("GCE_METADATA_ROOT", "remembered-for-restoration")
203
+ monkeypatch.delenv("GCE_METADATA_ROOT")
204
+ monkeypatch.delenv("GCE_METADATA_HOST", raising=False)
205
+ monkeypatch.setattr(store, "_on_google_cloud", lambda: True)
206
+ store.kvstore_specs("gs://bucket/vol")
207
+ assert "GCE_METADATA_ROOT" not in os.environ
208
+ monkeypatch.setattr(store, "_on_google_cloud", lambda: False)
209
+ store.kvstore_specs("gs://bucket/vol")
210
+ assert os.environ["GCE_METADATA_ROOT"] == "metadata.google.internal.invalid"
@@ -150,3 +150,36 @@ def test_lenient_units_take_unknown_units_as_the_target(tmp_path, caplog):
150
150
  )
151
151
  assert "nanometer" in wa.units.TABLE["length"] and "second" in wa.units.TABLE["time"]
152
152
  assert wa.open(str(group / "s0"), units=None).meta.fill_value in (None, 0)
153
+
154
+
155
+ def test_parent_group_funlib_attrs_place_an_attribute_less_array(tmp_path):
156
+ """funlib's keys on the parent group, the array carrying none, zarr and N5 alike
157
+ (mesh-n-bone's inputs): read as on the array, offset a corner."""
158
+ from conftest import write_n5, write_n5_group
159
+
160
+ for fmt in ("zarr2", "n5"):
161
+ root = tmp_path / f"vol.{fmt}"
162
+ if fmt == "zarr2":
163
+ write_zarr2_group(root, {"voxel_size": [1, 2, 3], "offset": [4, 4, 4]})
164
+ write_zarr2(root / "s0", pattern((4, 4, 4)))
165
+ else:
166
+ write_n5_group(root, {"voxel_size": [1, 2, 3], "offset": [4, 4, 4]})
167
+ write_n5(root / "s0", pattern((4, 4, 4)))
168
+ meta = wa.metadata(str(root / "s0"))
169
+ assert meta.voxel_size == (1.0, 2.0, 3.0), fmt
170
+ assert meta.translation == pytest.approx((4.5, 5.0, 5.5)), fmt
171
+ assert meta.placed_by == "funlib", fmt
172
+
173
+
174
+ def test_n5_array_funlib_keys_are_c_order(tmp_path):
175
+ """``voxel_size``/``offset`` under funlib's names on an N5 array are not reversed, unlike
176
+ ``resolution`` and ``pixelResolution``, because zarr-python's N5 store leaves user
177
+ attributes alone."""
178
+ from conftest import write_n5
179
+
180
+ root = tmp_path / "vol.n5"
181
+ write_n5(root / "s0", pattern((4, 4, 4)), attrs={"voxel_size": [1, 2, 3], "offset": [4, 4, 4]})
182
+ meta = wa.metadata(str(root / "s0"))
183
+ assert meta.voxel_size == (1.0, 2.0, 3.0)
184
+ assert meta.translation == pytest.approx((4.5, 5.0, 5.5))
185
+ assert meta.placed_by == "funlib"
@@ -218,13 +218,14 @@ def test_placement_agrees_across_formats(tmp_path):
218
218
  np.testing.assert_array_equal(vol.read(box), data[2:5, 2:5, 2:5], err_msg=str(path))
219
219
 
220
220
 
221
- def test_bigdataviewer_level_matches_ome_level(tmp_path):
221
+ def test_n5_downsampled_level_matches_ome_level(tmp_path):
222
222
  """An N5 pyramid with pixelResolution and downsamplingFactors and no offset sits where the
223
- equivalent OME pyramid (voxel 0 of s0 at the origin) puts its levels."""
223
+ equivalent OME pyramid with s0's corner at the origin puts its levels, as Neuroglancer
224
+ draws both: s0 centred at half a voxel, s1 at a whole base voxel (f / 2 * base)."""
224
225
  base, s1 = pattern((8, 8, 8)), pattern((4, 4, 4))
225
226
  ome = tmp_path / "ome.zarr"
226
227
  write_zarr2_group(
227
- ome, ome_multiscales("zyx", [("s0", (4, 4, 4), (0, 0, 0)), ("s1", (8, 8, 8), (2, 2, 2))])
228
+ ome, ome_multiscales("zyx", [("s0", (4, 4, 4), (2, 2, 2)), ("s1", (8, 8, 8), (4, 4, 4))])
228
229
  )
229
230
  write_zarr2(ome / "s0", base)
230
231
  write_zarr2(ome / "s1", s1)
@@ -232,10 +233,12 @@ def test_bigdataviewer_level_matches_ome_level(tmp_path):
232
233
  write_n5_group(n5, {"pixelResolution": {"dimensions": [4, 4, 4], "unit": "nm"}})
233
234
  write_n5(n5 / "s0", base, attrs={"downsamplingFactors": [1, 1, 1]})
234
235
  write_n5(n5 / "s1", s1, attrs={"downsamplingFactors": [2, 2, 2]})
235
- a, b = wa.open(str(ome / "s1")), wa.open(str(n5 / "s1"))
236
- assert a.translation == pytest.approx(b.translation, abs=0.4)
237
- box = ((6.0, 6.0, 6.0), (22.0, 22.0, 22.0))
238
- np.testing.assert_array_equal(a.read(box), b.read(box))
236
+ for level in ("s0", "s1"):
237
+ a, b = wa.open(str(ome / level)), wa.open(str(n5 / level))
238
+ assert a.translation == pytest.approx(b.translation), level
239
+ assert b.corner == pytest.approx((0.0, 0.0, 0.0)), level
240
+ box = ((6.0, 6.0, 6.0), (22.0, 22.0, 22.0))
241
+ np.testing.assert_array_equal(a.read(box), b.read(box))
239
242
 
240
243
 
241
244
  def test_chunks_covering(cube):
@@ -413,7 +416,7 @@ def test_base_voxel_size_on_a_unitless_n5_pyramid_warns_about_nothing(tmp_path,
413
416
  )
414
417
  assert not [r for r in caplog.records if "no unit" in r.message]
415
418
  assert s1.voxel_size == pytest.approx((1.0, 0.314, 0.314)) and s1.translation == pytest.approx(
416
- (0.0, 0.0785, 0.0785)
419
+ (0.5, 0.157, 0.157) # the corner at the origin at every level
417
420
  )
418
421
 
419
422
 
@@ -699,3 +702,41 @@ def test_callables_see_the_array_without_pinned_axes_and_empty_groups_say_so(tmp
699
702
  write_zarr2_group(empty)
700
703
  with pytest.raises(ValueError, match="no levels found"):
701
704
  wa.metadata(str(empty), level="first", open=False)
705
+
706
+
707
+ def test_precomputed_scale_path_is_that_scale_under_level_first(tmp_path):
708
+ """A path naming a scale is that scale, as an array path is that array; ``level=0`` and
709
+ ``"first"`` are silently it and any other level warns (mesh-n-bone's review)."""
710
+ from conftest import pattern
711
+
712
+ pre = tmp_path / "pc"
713
+ data = pattern((8, 8, 8))
714
+ write_precomputed(
715
+ pre,
716
+ [
717
+ {"data": data, "resolution": [4, 4, 4], "key": "4nm"},
718
+ {"data": data[::2, ::2, ::2], "resolution": [8, 8, 8], "key": "8nm"},
719
+ ],
720
+ )
721
+ for level in (None, 0, "first"):
722
+ vol = wa.open(str(pre / "8nm"), level=level)
723
+ assert (vol.level_name, vol.voxel_size) == ("8nm", (8.0, 8.0, 8.0)), level
724
+ light = wa.metadata(str(pre / "8nm"), level="first", open=False)
725
+ assert light.voxel_size == (8.0, 8.0, 8.0)
726
+ with pytest.warns(UserWarning, match="names a precomputed scale"):
727
+ assert wa.open(str(pre / "8nm"), level="4nm").level_name == "8nm"
728
+
729
+
730
+ def test_pyramid_opened_through_a_level_path_places_from_the_group_document(pyramid):
731
+ """``placements`` reads the group document whichever path opened the pyramid, so a
732
+ level whose array is gone is still placed and nothing is opened (mesh-n-bone's review)."""
733
+ import shutil
734
+
735
+ group, _ = pyramid
736
+ expected = wa.open_pyramid(group).placements()
737
+ shutil.rmtree(f"{group}/s2")
738
+ pyr = wa.open_pyramid(f"{group}/s0")
739
+ assert pyr.names == ["s0", "s1", "s2"]
740
+ assert pyr.placements() == expected
741
+ assert pyr.voxel_sizes()[-1] == ("s2", (16.0, 16.0, 16.0))
742
+ assert pyr.translations()[-1] == ("s2", (8.0, 8.0, 8.0))
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.1.0"
1250
+ version = "0.1.1"
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'" },
@@ -1,13 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0 (2026-10-21)
4
-
5
- - First version, built to `docs/spec.md`: open zarr v2, zarr v3, N5 and Neuroglancer
6
- precomputed arrays and pyramids through tensorstore; one metadata precedence per format
7
- with the voxel-centre rule for translations; index-space and world-box reads with
8
- padding; level selection; nearest and linear resampling; retries; shared and capped
9
- tensorstore contexts.
10
- Built and trialled in one day against the readers it replaces in cellmap-flow,
11
- cellmap-analyze, mesh-n-bone and chunkmirage, and checked on their own data by the
12
- CineMap, liconn-err, registration-station and salamander repos; see `docs/spec.md`,
13
- "Trial status". Hot-path reads cost 0.1 to 0.2 ms over a raw tensorstore read.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes