worldarray 0.3.0__tar.gz → 0.3.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 (36) hide show
  1. {worldarray-0.3.0 → worldarray-0.3.1}/CHANGELOG.md +15 -0
  2. {worldarray-0.3.0 → worldarray-0.3.1}/PKG-INFO +13 -4
  3. {worldarray-0.3.0 → worldarray-0.3.1}/README.md +12 -3
  4. {worldarray-0.3.0 → worldarray-0.3.1}/docs/spec.md +7 -5
  5. {worldarray-0.3.0 → worldarray-0.3.1}/pyproject.toml +1 -1
  6. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/array.py +20 -3
  8. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/resample.py +12 -4
  9. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/units.py +3 -1
  10. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_grid.py +1 -0
  11. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_read.py +22 -0
  12. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_write.py +1 -0
  13. {worldarray-0.3.0 → worldarray-0.3.1}/.github/workflows/ci.yml +0 -0
  14. {worldarray-0.3.0 → worldarray-0.3.1}/.github/workflows/docs.yml +0 -0
  15. {worldarray-0.3.0 → worldarray-0.3.1}/.github/workflows/publish.yml +0 -0
  16. {worldarray-0.3.0 → worldarray-0.3.1}/.gitignore +0 -0
  17. {worldarray-0.3.0 → worldarray-0.3.1}/LICENSE +0 -0
  18. {worldarray-0.3.0 → worldarray-0.3.1}/docs/changelog.md +0 -0
  19. {worldarray-0.3.0 → worldarray-0.3.1}/docs/index.md +0 -0
  20. {worldarray-0.3.0 → worldarray-0.3.1}/mkdocs.yml +0 -0
  21. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/context.py +0 -0
  22. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/geometry.py +0 -0
  23. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/grid.py +0 -0
  24. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/levels.py +0 -0
  25. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/placement.py +0 -0
  26. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/retry.py +0 -0
  27. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/store.py +0 -0
  28. {worldarray-0.3.0 → worldarray-0.3.1}/src/worldarray/write.py +0 -0
  29. {worldarray-0.3.0 → worldarray-0.3.1}/tests/conftest.py +0 -0
  30. {worldarray-0.3.0 → worldarray-0.3.1}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.3.0 → worldarray-0.3.1}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_access.py +0 -0
  33. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_geometry.py +0 -0
  34. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_metadata.py +0 -0
  35. {worldarray-0.3.0 → worldarray-0.3.1}/tests/test_resample.py +0 -0
  36. {worldarray-0.3.0 → worldarray-0.3.1}/uv.lock +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-10-22)
4
+
5
+ - `source_voxel_size` reports the stored level's voxel size under `on_mismatch="relabel"`,
6
+ as the spec says; it reported the relabelled size (cellmap-flow's report). Reads,
7
+ `voxel_size`, `translation` and `corner` are unchanged.
8
+ - `box` of an array opened with a resample target is the extent of the target voxels
9
+ `shape` counts, as the spec's `[corner, corner + shape * voxel_size)` says and as
10
+ `Grid.from_meta(meta, voxel_size=t).box` already was; it was the stored data's extent, so
11
+ `read(box)` could return fewer voxels than `shape` (cellmap-flow's 7 voxels of 8 nm at
12
+ 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).
15
+ - README: the placement rule gains the corner formula, a worked example, why the centre
16
+ is the model, and how the read rule follows from it.
17
+
3
18
  ## 0.3.0 (2026-10-22)
4
19
 
5
20
  Grids and blocks, built to spec draft 3 (Behaviours 17 to 19). Nothing on the read or
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.3.0
3
+ Version: 0.3.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/
@@ -165,9 +165,18 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
165
165
  ## The placement rule
166
166
 
167
167
  A translation is where the centre of voxel 0 is. Index `i` sits at
168
- `translation + i * voxel_size`; voxel `i` covers half a voxel either side. That is how
169
- OME-NGFF defines it and how Neuroglancer draws it. Dialects that store the corner are
170
- moved half a voxel on reading:
168
+ `translation + i * voxel_size` and covers half a voxel either side, so the array's corner
169
+ is `corner = translation - voxel_size / 2`. With 8 nm voxels and the corner at 0, the
170
+ translation is 4: voxel 0 covers 0 to 8 nm around its centre at 4, voxel 1 covers 8 to
171
+ 16 nm around 12. That is how OME-NGFF defines it and how Neuroglancer draws it.
172
+
173
+ The centre is the model because a voxel is a sample taken at a point. Everything that
174
+ compares or resamples grids (picking a level, reading one volume onto another's grid,
175
+ interpolating) works on those points, and the box a voxel covers follows from its point.
176
+ The read rule follows too: a voxel is read iff its centre lies in the box, so boxes that
177
+ adjoin never share a voxel or drop one.
178
+
179
+ Dialects that store the corner are moved half a voxel on reading:
171
180
 
172
181
  | Stored | Means | Read as `translation` |
173
182
  |---|---|---|
@@ -106,9 +106,18 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
106
106
  ## The placement rule
107
107
 
108
108
  A translation is where the centre of voxel 0 is. Index `i` sits at
109
- `translation + i * voxel_size`; voxel `i` covers half a voxel either side. That is how
110
- OME-NGFF defines it and how Neuroglancer draws it. Dialects that store the corner are
111
- moved half a voxel on reading:
109
+ `translation + i * voxel_size` and covers half a voxel either side, so the array's corner
110
+ is `corner = translation - voxel_size / 2`. With 8 nm voxels and the corner at 0, the
111
+ translation is 4: voxel 0 covers 0 to 8 nm around its centre at 4, voxel 1 covers 8 to
112
+ 16 nm around 12. That is how OME-NGFF defines it and how Neuroglancer draws it.
113
+
114
+ The centre is the model because a voxel is a sample taken at a point. Everything that
115
+ compares or resamples grids (picking a level, reading one volume onto another's grid,
116
+ interpolating) works on those points, and the box a voxel covers follows from its point.
117
+ The read rule follows too: a voxel is read iff its centre lies in the box, so boxes that
118
+ adjoin never share a voxel or drop one.
119
+
120
+ Dialects that store the corner are moved half a voxel on reading:
112
121
 
113
122
  | Stored | Means | Read as `translation` |
114
123
  |---|---|---|
@@ -149,7 +149,8 @@ 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; whether reads resample to a target
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
153
154
  vol.list_chunks() # the chunk indices that exist in storage, by listing keys (zarr v2/v3, N5, unsharded precomputed)
154
155
  vol.path # what the caller passed (a group, say); vol.level_path is the array actually opened
155
156
  vol.voxel_size # tuple[float] in `units`, spatial axes, the array's order (zyx for a volume)
@@ -212,7 +213,8 @@ window and a spatial box at once); anything with `.begin` and `.end`, or `.offse
212
213
  `shift`, `snap(voxel_size, translation, mode="grow" | "shrink" | "closest")` to a grid's
213
214
  voxel corners, `to_index` (the centre rule) and `Box.from_index`; `vol.box` is the array's
214
215
  own extent on the spatial axes, `[corner, corner + shape * voxel_size)`, what funlib calls
215
- the array's roi. So `vol.read(vol.box.intersect(roi).snap(vol.voxel_size, vol.translation))`
216
+ the array's roi; with a resample target, the target voxels `shape` counts, so `read(vol.box)`
217
+ returns `shape` and equals `Grid.from_meta(meta, voxel_size=t).box`. So `vol.read(vol.box.intersect(roi).snap(vol.voxel_size, vol.translation))`
216
218
  is the migration of a clipped, snapped Roi read, without funlib.
217
219
 
218
220
  There is no `offset` attribute anywhere in this API, because that word means the corner in
@@ -393,7 +395,7 @@ this document and that one differ, fix one of them.
393
395
  3. **Units are kept as stored and converted only on the axes a caller asks to convert**
394
396
  (`ArrayMeta.in_units(space=, time=)`, `in_nm()`, the `units` argument), through one table:
395
397
  for length, American and British spellings, `nm`, `um`, `µm` (U+00B5), `μm` (U+03BC),
396
- `mm`, `cm`, `m`, `angstrom`/`Å`, `pm`; for time, `s`/`second`, `ms`, `us`/`µs`, `ns`,
398
+ `mm`, `cm`, `m`, `km`, `angstrom`/`Å`, `pm`; for time, `s`/`second`, `ms`, `us`/`µs`, `ns`,
397
399
  `minute`, `hour`, `day`. Any unit string on any axis is accepted and never rejected until
398
400
  a conversion is asked for (`"s"`, `"m"`, `"degrees_north"`, `""`). A spatial axis with no
399
401
  unit is taken as nanometres, with a **warning once per array** (every repo assumed this
@@ -549,8 +551,8 @@ this document and that one differ, fix one of them.
549
551
  reads that level voxel for voxel and reports `voxel_size` as the requested size with
550
552
  **voxel `i` staying voxel `i`**: every world quantity, the translation and the corner
551
553
  included, scales by the ratio of the sizes (cellmap-flow's rule, which chunkmirage's
552
- server relies on), so a box of `m` requested voxels returns `m` stored voxels of the
553
- level; `"keep"` reads the chosen level at its own voxel size (a level pick, nothing
554
+ server relies on), so a box of `m` requested voxels returns `m` stored voxels of the level,
555
+ and `source_voxel_size` still reports the level's own size; `"keep"` reads the chosen level at its own voxel size (a level pick, nothing
554
556
  more). `relabel` and `nearest` are different things and both exist: one is a grid
555
557
  policy, the other a resampling kernel. `level` also takes a position, `0` or `"first"`
556
558
  for the first listed level. The pyramid an array belongs to is found through
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.3.0"
3
+ version = "0.3.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" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.3.0"
31
+ __version__ = "0.3.1"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -139,6 +139,8 @@ class WorldArray:
139
139
  self._meta = self._rescaled_to_base(base_voxel_size, base_units)
140
140
  space, time = _unit_targets(units)
141
141
  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
143
+ self._stored_voxel_size = tuple(self._grid.voxel_size[i] for i in self._grid.spatial)
142
144
 
143
145
  self._target: tuple[float, ...] | None = None
144
146
  if voxel_size is not None:
@@ -299,8 +301,9 @@ class WorldArray:
299
301
 
300
302
  @property
301
303
  def source_voxel_size(self) -> tuple[float, ...]:
302
- """The stored level's voxel size on the spatial axes, in ``units``."""
303
- return self._source_voxel_size()
304
+ """The stored level's voxel size on the spatial axes, in ``units``: what the level
305
+ is, whether reads resample it or ``on_mismatch="relabel"`` renames its grid."""
306
+ return self._stored_voxel_size
304
307
 
305
308
  @property
306
309
  def resampled(self) -> bool:
@@ -337,8 +340,22 @@ class WorldArray:
337
340
  def box(self) -> Box:
338
341
  """The array's extent on the spatial axes as a world box, ``[corner, corner + shape *
339
342
  voxel_size)``: what funlib calls the array's roi. ``Box.from_index`` gives the same
340
- over every axis."""
343
+ over every axis. With a resample target it is the extent of the target voxels that
344
+ ``shape`` counts, which may reach past the stored data by less than one target voxel,
345
+ so ``read(box)`` returns ``shape``."""
341
346
  sp = self._grid.spatial
347
+ if self._target is not None:
348
+ begin, end = [], []
349
+ for k, axis in enumerate(sp):
350
+ first, last = _resample.target_range(
351
+ self._grid.shape[axis],
352
+ self._grid.voxel_size[axis],
353
+ self._target[k],
354
+ self._origins()[k],
355
+ )
356
+ begin.append(self.anchor[k] + first * self._target[k])
357
+ end.append(self.anchor[k] + last * self._target[k])
358
+ return Box(tuple(begin), tuple(end))
342
359
  return Box.from_index(
343
360
  (0,) * len(sp),
344
361
  tuple(self._grid.shape[i] for i in sp),
@@ -153,14 +153,22 @@ def apply_axis(block: np.ndarray, axis: int, plan: AxisPlan) -> np.ndarray:
153
153
  raise ValueError(plan.kind)
154
154
 
155
155
 
156
+ def target_range(n: int, s: float, t: float, origin: float = 0.0) -> tuple[int, int]:
157
+ """``[first, last)``: the target voxels of size ``t`` that overlap ``n`` source voxels of
158
+ size ``s``, indexed on a target grid whose voxel 0 corner sits ``origin`` source voxels
159
+ from the source corner."""
160
+ ratio = t / s
161
+ first = int(np.floor(snap_integral(-origin / ratio)))
162
+ last = int(np.ceil(snap_integral((n - origin) / ratio)))
163
+ return first, max(last, first)
164
+
165
+
156
166
  def target_shape(n: int, s: float, t: float, origin: float = 0.0) -> int:
157
167
  """How many target voxels of size ``t`` cover ``n`` source voxels of size ``s``, counting
158
168
  from a target grid whose voxel 0 corner sits ``origin`` source voxels from the source
159
169
  corner: the first and last target voxels that overlap the data, inclusive."""
160
- ratio = t / s
161
- first = np.floor(snap_integral(-origin / ratio))
162
- last = np.ceil(snap_integral((n - origin) / ratio))
163
- return int(max(last - first, 0))
170
+ first, last = target_range(n, s, t, origin)
171
+ return last - first
164
172
 
165
173
 
166
174
  def resample(
@@ -19,6 +19,7 @@ LENGTH_M = {
19
19
  "millimeter": 1e-3, "millimetre": 1e-3, "mm": 1e-3,
20
20
  "centimeter": 1e-2, "centimetre": 1e-2, "cm": 1e-2,
21
21
  "meter": 1.0, "metre": 1.0, "m": 1.0,
22
+ "kilometer": 1e3, "kilometre": 1e3, "km": 1e3,
22
23
  } # fmt: skip
23
24
  TIME_S = {
24
25
  "nanosecond": 1e-9, "ns": 1e-9,
@@ -81,7 +82,7 @@ def factor(
81
82
  # entry through the table above, so "nanometre", "nm" and "Nanometers" agree.
82
83
  _SHORT = {
83
84
  "picometer": "pm", "angstrom": "Å", "nanometer": "nm", "micrometer": "µm",
84
- "millimeter": "mm", "centimeter": "cm", "meter": "m",
85
+ "millimeter": "mm", "centimeter": "cm", "meter": "m", "kilometer": "km",
85
86
  "nanosecond": "ns", "microsecond": "µs", "millisecond": "ms", "second": "s",
86
87
  "minute": "min", "hour": "h", "day": "d",
87
88
  } # fmt: skip
@@ -103,6 +104,7 @@ def abbreviation(unit: str) -> str:
103
104
  # The American spelling is OME's.
104
105
  _OME_NAMES = (
105
106
  "picometer", "angstrom", "nanometer", "micrometer", "millimeter", "centimeter", "meter",
107
+ "kilometer",
106
108
  "nanosecond", "microsecond", "millisecond", "second", "minute", "hour", "day",
107
109
  ) # fmt: skip
108
110
  _OME_BY_SIZE = {_lookup(name): name for name in _OME_NAMES}
@@ -299,6 +299,7 @@ SHORT_FORMS = {
299
299
  "mm": ("millimeter", "millimetre", "mm"),
300
300
  "cm": ("centimeter", "centimetre", "cm"),
301
301
  "m": ("meter", "metre", "m"),
302
+ "km": ("kilometer", "kilometre", "km"),
302
303
  "ns": ("nanosecond", "ns"),
303
304
  "µs": ("microsecond", "us", "µs", "μs"),
304
305
  "ms": ("millisecond", "ms"),
@@ -550,6 +550,28 @@ def test_relabel_rescales_everything_by_the_ratio(tmp_path):
550
550
  and vol.translation == (20.0, 20.0, 20.0)
551
551
  and vol.corner == (16.0, 16.0, 16.0)
552
552
  )
553
+ assert vol.source_voxel_size == (4.0, 4.0, 4.0) # what the level is (cellmap-flow's report)
554
+ resampled = wa.open(str(group / "s0"), voxel_size=(8, 8, 8), on_mismatch="resample")
555
+ assert resampled.source_voxel_size == (4.0, 4.0, 4.0)
556
+ assert resampled.voxel_size == (8.0, 8.0, 8.0)
557
+
558
+
559
+ def test_a_resampled_box_is_the_target_voxels_shape_counts(tmp_path):
560
+ """cellmap-flow's 7 voxels of 8 nm read at 24 nm: 3 target voxels, box 0 to 72, not 56."""
561
+ group = tmp_path / "rs.zarr"
562
+ write_zarr2_group(group, ome_multiscales("zyx", [("s0", (8, 8, 8), (4, 4, 4))]))
563
+ write_zarr2(group / "s0", pattern((7, 7, 7)))
564
+ vol = wa.open(str(group / "s0"), voxel_size=(24, 24, 24), on_mismatch="resample")
565
+ assert vol.shape == (3, 3, 3) and vol.box == wa.Box((0.0,) * 3, (72.0,) * 3)
566
+ assert vol.read(vol.box).shape == vol.shape
567
+ g = wa.Grid.from_meta(wa.open(str(group / "s0")).grid, voxel_size=(24, 24, 24))
568
+ assert g.box == vol.box # the planned grid and the opened array agree
569
+ # target corners at 4 + 24 k: the voxels from -20 cover the data from 0
570
+ anchored = wa.open(
571
+ str(group / "s0"), voxel_size=(24, 24, 24), on_mismatch="resample", anchor=(4, 4, 4)
572
+ )
573
+ assert anchored.shape == (4, 4, 4) and anchored.box == wa.Box((-20.0,) * 3, (76.0,) * 3)
574
+ assert anchored.read(anchored.box).shape == anchored.shape
553
575
 
554
576
 
555
577
  def test_post_transform_runs_after_resampling_and_before_padding(tmp_path):
@@ -57,6 +57,7 @@ def test_units_are_written_in_ome_names_and_unknown_ones_as_given():
57
57
  assert wa.units.ome_name(spelling) == "nanometer"
58
58
  assert wa.units.ome_name("um") == wa.units.ome_name("\u00b5m") == "micrometer"
59
59
  assert wa.units.ome_name("s") == "second"
60
+ assert wa.units.ome_name("km") == wa.units.ome_name("Kilometres") == "kilometer"
60
61
  with pytest.raises(ValueError, match="unknown unit"):
61
62
  wa.units.ome_name("pixel")
62
63
  doc = wa.ome_multiscales(("z", "y", "x"), "pixel", [("s0", (1, 1, 1), (0.5,) * 3)])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes