worldarray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
worldarray/context.py ADDED
@@ -0,0 +1,53 @@
1
+ """Tensorstore contexts: one shared per cache budget, or a capped one per call.
2
+
3
+ ``shared_context(cache_bytes)`` is one ``tensorstore.Context`` per budget for the whole
4
+ process, so the levels of a pyramid and repeat opens of an array draw on one cache pool.
5
+ The default budget is 0: tensorstore's decoded-chunk cache is off, because every consumer
6
+ either reads each region once or caches decoded chunks itself.
7
+
8
+ ``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
9
+ resource limited, so dozens of worker processes on one host do not multiply into thousands
10
+ of threads. It returns a **new** context each call, as those workers inline the spec into
11
+ every open today; pass the same object twice to share pools (spec, Behaviour 7).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+
18
+ import tensorstore as ts
19
+
20
+ _shared: dict[int, ts.Context] = {}
21
+ _lock = threading.Lock()
22
+
23
+
24
+ def shared_context(cache_bytes: int = 0) -> ts.Context:
25
+ with _lock:
26
+ ctx = _shared.get(int(cache_bytes))
27
+ if ctx is None:
28
+ ctx = ts.Context({"cache_pool": {"total_bytes_limit": int(cache_bytes)}})
29
+ _shared[int(cache_bytes)] = ctx
30
+ return ctx
31
+
32
+
33
+ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> dict:
34
+ """The spec dict behind ``capped_context``, for callers building their own opens.
35
+ ``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
36
+ pools alone (cellmap-analyze's choice for remote reads)."""
37
+ limit = {"limit": int(concurrency)}
38
+ spec = {
39
+ "data_copy_concurrency": limit,
40
+ "file_io_concurrency": limit,
41
+ "cache_pool": {"total_bytes_limit": int(cache_bytes)},
42
+ }
43
+ if network:
44
+ spec.update(
45
+ gcs_request_concurrency=limit,
46
+ s3_request_concurrency=limit,
47
+ http_request_concurrency=limit,
48
+ )
49
+ return spec
50
+
51
+
52
+ def capped_context(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> ts.Context:
53
+ return ts.Context(capped_context_spec(cache_bytes, concurrency, network))
worldarray/geometry.py ADDED
@@ -0,0 +1,173 @@
1
+ """Boxes, and the one rule that maps a world box to voxel indices.
2
+
3
+ A world box ``[begin, end)`` is half-open per axis. A voxel is read iff its **centre**
4
+ lies in the box: with voxel size ``s`` and translation ``t`` (the centre of voxel 0), the
5
+ voxels are ``ceil((begin - t) / s) <= i < ceil((end - t) / s)``, after float noise is
6
+ snapped away. A box aligned to voxel corners (every blockwise block) and a box aligned to
7
+ voxel centres (mesh-n-bone's) both map to whole voxels with nothing rounded
8
+ (spec, Behaviour 2).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+
18
+ SNAP_TOL = 1e-6 # of a voxel: a quotient this close to a whole number is that whole number
19
+
20
+
21
+ def snap_integral(values, tol: float = SNAP_TOL) -> np.ndarray:
22
+ arr = np.asarray(values, dtype=float)
23
+ rounded = np.round(arr)
24
+ return np.where(np.abs(arr - rounded) <= tol, rounded, arr)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Box:
29
+ """``[begin, end)`` per axis, in world units (floats) or voxels (whole floats)."""
30
+
31
+ begin: tuple[float, ...]
32
+ end: tuple[float, ...]
33
+
34
+ def __post_init__(self) -> None:
35
+ if len(self.begin) != len(self.end):
36
+ raise ValueError(f"begin and end differ in length: {self.begin} vs {self.end}")
37
+ object.__setattr__(self, "begin", tuple(float(b) for b in self.begin))
38
+ object.__setattr__(self, "end", tuple(float(e) for e in self.end))
39
+
40
+ @property
41
+ def shape(self) -> tuple[float, ...]:
42
+ return tuple(e - b for b, e in zip(self.begin, self.end))
43
+
44
+ @property
45
+ def ndim(self) -> int:
46
+ return len(self.begin)
47
+
48
+ @property
49
+ def centre(self) -> tuple[float, ...]:
50
+ return tuple((b + e) / 2 for b, e in zip(self.begin, self.end))
51
+
52
+ @property
53
+ def empty(self) -> bool:
54
+ return any(e <= b for b, e in zip(self.begin, self.end))
55
+
56
+ def intersect(self, other) -> Box:
57
+ """The overlap of two boxes; a box of zero extent where there is none."""
58
+ other = Box.coerce(other)
59
+ begin = tuple(max(a, b) for a, b in zip(self.begin, other.begin))
60
+ end = tuple(max(bg, min(a, b)) for bg, a, b in zip(begin, self.end, other.end))
61
+ return Box(begin, end)
62
+
63
+ def union(self, other) -> Box:
64
+ """The smallest box holding both."""
65
+ other = Box.coerce(other)
66
+ return Box(
67
+ tuple(min(a, b) for a, b in zip(self.begin, other.begin)),
68
+ tuple(max(a, b) for a, b in zip(self.end, other.end)),
69
+ )
70
+
71
+ def contains(self, other) -> bool:
72
+ """Whether a point (one coordinate per axis) lies in the box, or another box lies
73
+ within it. Half-open, so a point on the far edge is outside."""
74
+ if isinstance(other, Box) or hasattr(other, "begin") or hasattr(other, "offset"):
75
+ o = Box.coerce(other)
76
+ return all(
77
+ b <= ob and oe <= e for b, e, ob, oe in zip(self.begin, self.end, o.begin, o.end)
78
+ )
79
+ return all(b <= p < e for b, e, p in zip(self.begin, self.end, other))
80
+
81
+ def grow(self, before, after=None) -> Box:
82
+ """Extended by ``before`` on the low side and ``after`` (default ``before``) on the
83
+ high side, a scalar or one value per axis; negative values shrink."""
84
+ before = self._per_axis(before)
85
+ after = self._per_axis(before if after is None else after)
86
+ return Box(
87
+ tuple(b - x for b, x in zip(self.begin, before)),
88
+ tuple(e + x for e, x in zip(self.end, after)),
89
+ )
90
+
91
+ def shift(self, by) -> Box:
92
+ by = self._per_axis(by)
93
+ return Box(
94
+ tuple(b + x for b, x in zip(self.begin, by)), tuple(e + x for e, x in zip(self.end, by))
95
+ )
96
+
97
+ def snap(self, voxel_size, translation, mode: str = "grow") -> Box:
98
+ """Aligned to the voxel corners of a grid whose voxel 0 is centred at
99
+ ``translation``: ``grow`` rounds outward, ``shrink`` inward, ``closest`` to the
100
+ nearest corner. After ``grow`` the box reads exactly the voxels it touches."""
101
+ vs = np.asarray(voxel_size, dtype=float)
102
+ corner0 = np.asarray(translation, dtype=float) - vs / 2
103
+ qb = snap_integral((np.asarray(self.begin) - corner0) / vs)
104
+ qe = snap_integral((np.asarray(self.end) - corner0) / vs)
105
+ if mode == "grow":
106
+ qb, qe = np.floor(qb), np.ceil(qe)
107
+ elif mode == "shrink":
108
+ qb, qe = np.ceil(qb), np.floor(qe)
109
+ elif mode == "closest":
110
+ qb, qe = np.round(qb), np.round(qe)
111
+ else:
112
+ raise ValueError("mode is 'grow', 'shrink' or 'closest'")
113
+ return Box(tuple(corner0 + qb * vs), tuple(corner0 + np.maximum(qe, qb) * vs))
114
+
115
+ def to_index(self, voxel_size, translation) -> tuple[tuple[int, ...], tuple[int, ...]]:
116
+ """The voxels whose centres lie in the box (the centre rule)."""
117
+ return index_range(self.begin, self.end, voxel_size, translation)
118
+
119
+ @classmethod
120
+ def from_index(cls, begin, end, voxel_size, translation) -> Box:
121
+ """The world box exactly covering voxels ``[begin, end)`` of a grid."""
122
+ vs = np.asarray(voxel_size, dtype=float)
123
+ corner0 = np.asarray(translation, dtype=float) - vs / 2
124
+ return cls(tuple(corner0 + np.asarray(begin) * vs), tuple(corner0 + np.asarray(end) * vs))
125
+
126
+ def _per_axis(self, value) -> tuple[float, ...]:
127
+ if np.ndim(value) == 0:
128
+ return (float(value),) * self.ndim
129
+ value = tuple(float(v) for v in value)
130
+ if len(value) != self.ndim:
131
+ raise ValueError(f"expected {self.ndim} values, got {value}")
132
+ return value
133
+
134
+ @classmethod
135
+ def coerce(cls, box: Any) -> Box:
136
+ """A ``Box`` from a ``(begin, end)`` pair, anything with ``.begin``/``.end`` or
137
+ ``.offset``/``.shape`` (a funlib ``Roi``, duck-typed), or a ``Box``."""
138
+ if isinstance(box, Box):
139
+ return box
140
+ if hasattr(box, "begin") and hasattr(box, "end"):
141
+ return cls(tuple(box.begin), tuple(box.end))
142
+ if hasattr(box, "offset") and hasattr(box, "shape"):
143
+ offset, shape = tuple(box.offset), tuple(box.shape)
144
+ return cls(offset, tuple(o + s for o, s in zip(offset, shape)))
145
+ try:
146
+ begin, end = box
147
+ return cls(tuple(begin), tuple(end))
148
+ except (TypeError, ValueError):
149
+ raise TypeError(
150
+ "a box is a (begin, end) pair, an object with .begin/.end or .offset/.shape, or a Box"
151
+ ) from None
152
+
153
+
154
+ def index_range(begin, end, voxel_size, translation) -> tuple[tuple[int, ...], tuple[int, ...]]:
155
+ """The half-open voxel index range whose centres lie in ``[begin, end)``."""
156
+ s = np.asarray(voxel_size, dtype=float)
157
+ t = np.asarray(translation, dtype=float)
158
+ lo = np.ceil(snap_integral((np.asarray(begin, dtype=float) - t) / s))
159
+ hi = np.ceil(snap_integral((np.asarray(end, dtype=float) - t) / s))
160
+ hi = np.maximum(hi, lo)
161
+ return tuple(int(v) for v in lo), tuple(int(v) for v in hi)
162
+
163
+
164
+ def is_aligned(begin, end, voxel_size, translation) -> bool:
165
+ """Whether every edge sits on a voxel corner or a voxel centre."""
166
+ s = np.asarray(voxel_size, dtype=float)
167
+ t = np.asarray(translation, dtype=float)
168
+ for edges in (begin, end):
169
+ q = (np.asarray(edges, dtype=float) - t) / s
170
+ twice = snap_integral(2 * q)
171
+ if not np.all(twice == np.round(twice)):
172
+ return False
173
+ return True
worldarray/levels.py ADDED
@@ -0,0 +1,147 @@
1
+ """The levels of a pyramid, and choosing one for a voxel size.
2
+
3
+ A group's levels are the paths in its ``multiscales[0].datasets`` (OME-NGFF, COSEM N5), in
4
+ that order; without that, ``s0, s1, ...`` are probed. A precomputed volume's levels are its
5
+ ``scales``, in order. Levels are matched by name, never by position (spec, Conventions 4;
6
+ Behaviour 3).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ import numpy as np
16
+
17
+ from .placement import ArrayMeta
18
+ from .store import Location, Node, basename, detect, is_root, join, parent
19
+
20
+ Mode = Literal["floor", "finer", "exact", "nearest", "resample"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class LevelRef:
25
+ name: str
26
+ path: str # the array path (for precomputed, the volume root)
27
+ scale_index: int | None = None # precomputed only
28
+
29
+
30
+ def _group_multiscale_paths(node: Node) -> list[str]:
31
+ multiscales = node.multiscales
32
+ if not multiscales:
33
+ return []
34
+ return [str(ds["path"]).strip("/") for ds in multiscales[0].get("datasets", []) if "path" in ds]
35
+
36
+
37
+ def _probe_levels(path: str) -> list[str]:
38
+ names: list[str] = []
39
+ for i in range(64):
40
+ name = f"s{i}"
41
+ docs = Location(join(path, name)).read_many(("zarr.json", ".zarray", "attributes.json"))
42
+ if all(v is None for v in docs.values()):
43
+ break
44
+ names.append(name)
45
+ return names
46
+
47
+
48
+ def discover(node: Node) -> list[LevelRef]:
49
+ """The levels of a group node, or of the group an array node belongs to (just the array
50
+ itself when its parent is not a multiscale group)."""
51
+ if node.format == "precomputed":
52
+ return [
53
+ LevelRef(str(s.get("key", i)), node.path, i)
54
+ for i, s in enumerate(node.docs["info"]["scales"])
55
+ ]
56
+ if node.kind == "array":
57
+ ancestor, rel = parent(node.path), basename(node.path)
58
+ for _ in range(4): # a dataset path may have more than one component ("seg/s0")
59
+ if is_root(ancestor):
60
+ break
61
+ try:
62
+ group = detect(ancestor, missing_ok=True)
63
+ except Exception: # noqa: BLE001 (a server root answering 301, say)
64
+ break
65
+ if (
66
+ group is not None
67
+ and group.kind == "group"
68
+ and rel in _group_multiscale_paths(group)
69
+ ):
70
+ return discover(group)
71
+ if parent(ancestor) is None:
72
+ break
73
+ rel = f"{basename(ancestor)}/{rel}"
74
+ ancestor = parent(ancestor)
75
+ return [LevelRef(basename(node.path), node.path)]
76
+ names = _group_multiscale_paths(node) or _probe_levels(node.path)
77
+ return [LevelRef(name, join(node.path, name)) for name in names]
78
+
79
+
80
+ def _spatial_voxel_size(level) -> np.ndarray:
81
+ """The spatial voxel size of an ``ArrayMeta``, or of a plain per-spatial-axis tuple."""
82
+ if hasattr(level, "spatial"):
83
+ return np.asarray([level.voxel_size[i] for i in level.spatial], dtype=float)
84
+ return np.asarray(level, dtype=float)
85
+
86
+
87
+ def same_voxel_size(a, b) -> bool:
88
+ a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
89
+ return a.shape == b.shape and bool(np.allclose(a, b, rtol=1e-6, atol=0))
90
+
91
+
92
+ def coarser_anywhere(resolution, target) -> bool:
93
+ r, t = np.asarray(resolution, dtype=float), np.asarray(target, dtype=float)
94
+ return bool(np.any((r > t) & ~np.isclose(r, t, rtol=1e-6, atol=0)))
95
+
96
+
97
+ def select_level(
98
+ levels: Sequence[tuple[str, ArrayMeta | tuple[float, ...]]], voxel_size, mode: Mode = "floor"
99
+ ) -> tuple[str, ArrayMeta | tuple[float, ...]]:
100
+ """The ``(name, meta)`` to read at ``voxel_size`` (spatial axes, the pyramid's units).
101
+
102
+ - ``floor``: the level at ``voxel_size``, else the coarsest level that is no coarser than
103
+ it on any axis, else the first.
104
+ - ``finer``: as ``floor``, but ``ValueError`` instead of falling back to the first level
105
+ when every level is coarser than the target on some axis (cellmap-analyze's rule).
106
+ - ``exact``: the level at ``voxel_size``, else ``ValueError`` listing the levels.
107
+ - ``nearest``: the level closest on a log scale summed over the axes; the finer on a tie.
108
+ - ``resample``: the level to resample from: the level at ``voxel_size`` if there is one;
109
+ else the coarsest level that is no coarser on any axis; else the finest.
110
+ """
111
+ if not levels:
112
+ raise ValueError("no levels to choose from")
113
+ target = np.asarray(voxel_size, dtype=float)
114
+ sizes = [(name, meta, _spatial_voxel_size(meta)) for name, meta in levels]
115
+ if target.shape != sizes[0][2].shape:
116
+ raise ValueError(
117
+ f"voxel_size has {target.size} entries, the levels have {sizes[0][2].size} spatial axes"
118
+ )
119
+ for name, meta, vs in sizes:
120
+ if same_voxel_size(vs, target):
121
+ return name, meta
122
+ if mode == "exact":
123
+ raise ValueError(f"no level at voxel size {target.tolist()}; levels: " + _describe(sizes))
124
+ if mode in ("floor", "finer", "resample"):
125
+ candidates = [(name, meta) for name, meta, vs in sizes if not coarser_anywhere(vs, target)]
126
+ if not candidates:
127
+ if mode == "finer":
128
+ raise ValueError(
129
+ f"every level is coarser than {target.tolist()} on some axis; levels: "
130
+ + _describe(sizes)
131
+ )
132
+ return levels[0]
133
+ # the coarsest of them: largest voxel volume
134
+ return max(candidates, key=lambda nm: float(np.prod(_spatial_voxel_size(nm[1]))))
135
+ if mode == "nearest":
136
+
137
+ def distance(entry):
138
+ vs = entry[2]
139
+ return float(np.sum(np.abs(np.log(vs) - np.log(target)))), float(np.prod(vs))
140
+
141
+ name, meta, _ = min(sizes, key=distance)
142
+ return name, meta
143
+ raise ValueError(f"unknown mode {mode!r}")
144
+
145
+
146
+ def _describe(sizes) -> str:
147
+ return ", ".join(f"{name} {vs.tolist()}" for name, _, vs in sizes)