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/__init__.py +43 -0
- worldarray/array.py +935 -0
- worldarray/context.py +53 -0
- worldarray/geometry.py +173 -0
- worldarray/levels.py +147 -0
- worldarray/placement.py +739 -0
- worldarray/resample.py +229 -0
- worldarray/retry.py +76 -0
- worldarray/store.py +427 -0
- worldarray/units.py +77 -0
- worldarray-0.1.0.dist-info/METADATA +214 -0
- worldarray-0.1.0.dist-info/RECORD +14 -0
- worldarray-0.1.0.dist-info/WHEEL +4 -0
- worldarray-0.1.0.dist-info/licenses/LICENSE +28 -0
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)
|