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/resample.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Reading at another voxel size, axis by axis.
|
|
2
|
+
|
|
3
|
+
The target grid's voxel 0 starts at the source **corner** (or at an ``origin`` the caller
|
|
4
|
+
anchors elsewhere) and target voxel ``j`` is centred at ``corner + (j + 1/2) * t``, which is
|
|
5
|
+
source index ``p = origin + (j + 1/2) * t / s - 1/2``.
|
|
6
|
+
|
|
7
|
+
``"nearest"`` takes the source voxel whose extent holds the target centre, nearest neighbour
|
|
8
|
+
as ITK defines it; a centre on a voxel boundary (every even whole factor) belongs to the
|
|
9
|
+
upper voxel. ``"linear"`` takes the block mean when ``t / s`` is a whole number of 2 or more
|
|
10
|
+
and the grids are aligned (a block cut short by the end of the data is the mean of what it
|
|
11
|
+
has), else linear interpolation between the two neighbours, the border voxel repeated past
|
|
12
|
+
the first and last centre. Integer intensity dtypes are rounded back.
|
|
13
|
+
|
|
14
|
+
A target voxel outside the data (its centre past the data, or for a block mean a block that
|
|
15
|
+
overlaps none of it) is filled: with the padding value, or for ``"edge"`` with the border
|
|
16
|
+
voxel (nearest, linear) or the last block that holds data (mean). The source voxels read come from the target centres, grown by
|
|
17
|
+
the kernel radius, never from the box edges, so a target voxel's value depends only on its
|
|
18
|
+
index and the source array: two adjoining reads agree with one read across both (spec,
|
|
19
|
+
Behaviour 4).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from .geometry import snap_integral
|
|
30
|
+
|
|
31
|
+
Method = str # "nearest" | "linear"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class AxisPlan:
|
|
36
|
+
"""How one spatial axis is resampled for target indices ``[jb, je)``."""
|
|
37
|
+
|
|
38
|
+
lo: int # source read range [lo, hi)
|
|
39
|
+
hi: int
|
|
40
|
+
inside: np.ndarray # per target voxel: it holds data (see the module docstring)
|
|
41
|
+
kind: str # "same" | "nearest" | "linear" | "mean"
|
|
42
|
+
index: np.ndarray | None = None # nearest: source index per target voxel (clamped)
|
|
43
|
+
lower: np.ndarray | None = None # linear: lower neighbour (clamped), weight on the upper
|
|
44
|
+
weight: np.ndarray | None = None
|
|
45
|
+
start: np.ndarray | None = None # mean: block [start, stop) per target voxel (clamped)
|
|
46
|
+
stop: np.ndarray | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _clip_to_inside(j: np.ndarray, inside: np.ndarray) -> np.ndarray:
|
|
50
|
+
"""Target indices moved onto the nearest inside target voxel (edge padding)."""
|
|
51
|
+
if not inside.any() or inside.all():
|
|
52
|
+
return j
|
|
53
|
+
held = j[inside]
|
|
54
|
+
return np.clip(j, held.min(), held.max())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def plan_axis(
|
|
58
|
+
n: int,
|
|
59
|
+
s: float,
|
|
60
|
+
t: float,
|
|
61
|
+
jb: int,
|
|
62
|
+
je: int,
|
|
63
|
+
method: Method,
|
|
64
|
+
origin: float = 0.0,
|
|
65
|
+
edge: bool = False,
|
|
66
|
+
) -> AxisPlan:
|
|
67
|
+
"""``origin``: the target grid's voxel 0 corner, in source voxels from the source corner
|
|
68
|
+
(0 when the two grids share a corner). ``edge``: outside target voxels repeat the
|
|
69
|
+
nearest inside one instead of taking the fill."""
|
|
70
|
+
j = np.arange(jb, je, dtype=float)
|
|
71
|
+
ratio = t / s
|
|
72
|
+
if je <= jb:
|
|
73
|
+
return AxisPlan(0, 0, np.zeros(0, dtype=bool), "same")
|
|
74
|
+
whole = snap_integral(ratio)
|
|
75
|
+
aligned = float(snap_integral(origin)).is_integer()
|
|
76
|
+
if method == "linear" and whole == np.round(whole) and whole >= 2 and aligned:
|
|
77
|
+
f, o = int(whole), int(round(float(origin)))
|
|
78
|
+
raw_start, raw_stop = o + j * f, o + (j + 1) * f
|
|
79
|
+
inside = (raw_start < n) & (raw_stop > 0) # the block overlaps the data at all
|
|
80
|
+
if edge:
|
|
81
|
+
jj = _clip_to_inside(j, inside)
|
|
82
|
+
raw_start, raw_stop = o + jj * f, o + (jj + 1) * f
|
|
83
|
+
start = np.clip(raw_start.astype(int), 0, n)
|
|
84
|
+
stop = np.clip(raw_stop.astype(int), 0, n)
|
|
85
|
+
lo = int(start[inside].min()) if inside.any() else 0
|
|
86
|
+
hi = int(stop[inside].max()) if inside.any() else 0
|
|
87
|
+
return AxisPlan(lo, hi, inside, "mean", start=start, stop=stop)
|
|
88
|
+
|
|
89
|
+
centre = snap_integral(origin + (j + 0.5) * ratio) # in source voxels from the corner
|
|
90
|
+
inside = (centre >= 0) & (centre < n)
|
|
91
|
+
# for nearest and linear, "edge" is the clamped source index below: the border voxel repeated
|
|
92
|
+
if method == "nearest":
|
|
93
|
+
# the source voxel whose extent [i, i + 1) holds the target centre: nearest neighbour as
|
|
94
|
+
# ITK defines it; a centre on a boundary belongs to the upper voxel
|
|
95
|
+
idx = np.clip(np.floor(centre), 0, n - 1).astype(int)
|
|
96
|
+
lo, hi = _bounds(idx[inside] if not edge else idx, n)
|
|
97
|
+
return AxisPlan(lo, hi, inside, "nearest", index=idx)
|
|
98
|
+
if method == "linear":
|
|
99
|
+
p = snap_integral(centre - 0.5) # source index of the target centre
|
|
100
|
+
pc = np.clip(p, 0, n - 1) # border repeated past the first and last centre
|
|
101
|
+
lower = np.floor(pc).astype(int)
|
|
102
|
+
weight = pc - lower
|
|
103
|
+
upper_needed = np.minimum(lower + 1, n - 1)
|
|
104
|
+
used = np.ones_like(inside) if edge else inside
|
|
105
|
+
lo, hi = _bounds(np.concatenate([lower[used], upper_needed[used]]), n)
|
|
106
|
+
return AxisPlan(lo, hi, inside, "linear", lower=lower, weight=weight)
|
|
107
|
+
raise ValueError(f"unknown method {method!r}; use 'nearest' or 'linear'")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _bounds(indices: np.ndarray, n: int) -> tuple[int, int]:
|
|
111
|
+
if indices.size == 0:
|
|
112
|
+
return 0, 0
|
|
113
|
+
return int(indices.min()), int(indices.max()) + 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def apply_axis(block: np.ndarray, axis: int, plan: AxisPlan) -> np.ndarray:
|
|
117
|
+
"""``block`` (read over ``[plan.lo, plan.hi)`` along ``axis``) resampled along it."""
|
|
118
|
+
if plan.kind == "same":
|
|
119
|
+
return block
|
|
120
|
+
if plan.kind == "nearest":
|
|
121
|
+
idx = np.clip(plan.index - plan.lo, 0, max(block.shape[axis] - 1, 0))
|
|
122
|
+
return np.take(block, idx, axis=axis)
|
|
123
|
+
if plan.kind == "linear":
|
|
124
|
+
last = max(block.shape[axis] - 1, 0)
|
|
125
|
+
lower = np.clip(plan.lower - plan.lo, 0, last)
|
|
126
|
+
upper = np.clip(plan.lower + 1 - plan.lo, 0, last)
|
|
127
|
+
w = plan.weight.reshape([-1 if a == axis else 1 for a in range(block.ndim)])
|
|
128
|
+
lo_v = np.take(block, lower, axis=axis).astype(np.float64)
|
|
129
|
+
hi_v = np.take(block, upper, axis=axis).astype(np.float64)
|
|
130
|
+
return lo_v * (1 - w) + hi_v * w
|
|
131
|
+
if plan.kind == "mean":
|
|
132
|
+
start = np.clip(plan.start - plan.lo, 0, block.shape[axis])
|
|
133
|
+
stop = np.clip(plan.stop - plan.lo, 0, block.shape[axis])
|
|
134
|
+
if (
|
|
135
|
+
start.size
|
|
136
|
+
and np.all(stop[:-1] == start[1:])
|
|
137
|
+
and np.all(stop > start)
|
|
138
|
+
and start[0] == 0
|
|
139
|
+
and stop[-1] == block.shape[axis]
|
|
140
|
+
): # the blocks tile the read: one pass, no cumulative sum
|
|
141
|
+
sums = np.add.reduceat(block, start, axis=axis, dtype=np.float64)
|
|
142
|
+
count = (stop - start).reshape([-1 if a == axis else 1 for a in range(block.ndim)])
|
|
143
|
+
return sums / count
|
|
144
|
+
cs = np.cumsum(block.astype(np.float64), axis=axis)
|
|
145
|
+
zero = np.zeros_like(np.take(cs, [0], axis=axis))
|
|
146
|
+
cs = np.concatenate([zero, cs], axis=axis)
|
|
147
|
+
start = np.clip(plan.start - plan.lo, 0, block.shape[axis])
|
|
148
|
+
stop = np.clip(plan.stop - plan.lo, 0, block.shape[axis])
|
|
149
|
+
count = np.maximum(stop - start, 1).reshape(
|
|
150
|
+
[-1 if a == axis else 1 for a in range(block.ndim)]
|
|
151
|
+
)
|
|
152
|
+
return (np.take(cs, stop, axis=axis) - np.take(cs, start, axis=axis)) / count
|
|
153
|
+
raise ValueError(plan.kind)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def target_shape(n: int, s: float, t: float, origin: float = 0.0) -> int:
|
|
157
|
+
"""How many target voxels of size ``t`` cover ``n`` source voxels of size ``s``, counting
|
|
158
|
+
from a target grid whose voxel 0 corner sits ``origin`` source voxels from the source
|
|
159
|
+
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))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resample(
|
|
167
|
+
read_index: Callable[[Sequence[int], Sequence[int], object], np.ndarray],
|
|
168
|
+
shape: Sequence[int],
|
|
169
|
+
dtype: np.dtype,
|
|
170
|
+
spatial: Sequence[int],
|
|
171
|
+
source_voxel_size: Sequence[float],
|
|
172
|
+
target_voxel_size: Sequence[float],
|
|
173
|
+
begin: Sequence[int],
|
|
174
|
+
end: Sequence[int],
|
|
175
|
+
method: Method,
|
|
176
|
+
padding,
|
|
177
|
+
mask: bool,
|
|
178
|
+
origins: Sequence[float] | None = None,
|
|
179
|
+
) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
|
|
180
|
+
"""The target voxels ``[begin, end)`` (target indices on the spatial axes, source indices
|
|
181
|
+
elsewhere) of the source read through ``read_index(begin, end, padding)``. ``origins``:
|
|
182
|
+
per spatial axis, the target grid's corner in source voxels from the source corner.
|
|
183
|
+
``dtype`` is what the output is cast to when the block read comes back as the stored
|
|
184
|
+
dtype; a ``read_index`` that transforms its block decides the dtype instead."""
|
|
185
|
+
ndim = len(shape)
|
|
186
|
+
edge = padding == "edge"
|
|
187
|
+
plans: dict[int, AxisPlan] = {}
|
|
188
|
+
read_begin, read_end = list(begin), list(end)
|
|
189
|
+
for k, axis in enumerate(spatial):
|
|
190
|
+
origin = float(origins[k]) if origins is not None else 0.0
|
|
191
|
+
plan = plan_axis(
|
|
192
|
+
shape[axis],
|
|
193
|
+
source_voxel_size[k],
|
|
194
|
+
target_voxel_size[k],
|
|
195
|
+
begin[axis],
|
|
196
|
+
end[axis],
|
|
197
|
+
method,
|
|
198
|
+
origin,
|
|
199
|
+
edge,
|
|
200
|
+
)
|
|
201
|
+
plans[axis] = plan
|
|
202
|
+
read_begin[axis], read_end[axis] = plan.lo, plan.hi
|
|
203
|
+
|
|
204
|
+
out_shape = tuple(int(e - b) for b, e in zip(begin, end))
|
|
205
|
+
inside = np.ones(out_shape, dtype=bool)
|
|
206
|
+
for axis, plan in plans.items():
|
|
207
|
+
inside &= plan.inside.reshape([-1 if a == axis else 1 for a in range(ndim)])
|
|
208
|
+
|
|
209
|
+
if not inside.any() or any(read_end[a] <= read_begin[a] for a in plans):
|
|
210
|
+
fill = 0 if edge else padding
|
|
211
|
+
out = np.full(out_shape, fill, dtype=dtype)
|
|
212
|
+
return (out, inside) if mask else out
|
|
213
|
+
|
|
214
|
+
# the spatial axes are read exactly; the other axes pad as any index read does
|
|
215
|
+
block = read_index(read_begin, read_end, padding)
|
|
216
|
+
out_dtype = block.dtype if block.dtype != dtype else dtype
|
|
217
|
+
out = block
|
|
218
|
+
for axis in sorted(plans, key=lambda a: plans[a].kind != "mean"):
|
|
219
|
+
out = apply_axis(out, axis, plans[axis])
|
|
220
|
+
if np.issubdtype(out_dtype, np.integer) or np.issubdtype(out_dtype, np.bool_):
|
|
221
|
+
out = np.rint(out) if out.dtype.kind == "f" else out
|
|
222
|
+
if np.issubdtype(out_dtype, np.integer):
|
|
223
|
+
info = np.iinfo(out_dtype)
|
|
224
|
+
out = np.clip(out, info.min, info.max)
|
|
225
|
+
out = np.ascontiguousarray(out.astype(out_dtype, copy=False))
|
|
226
|
+
if not edge and not inside.all():
|
|
227
|
+
out = out.copy()
|
|
228
|
+
out[~inside] = padding
|
|
229
|
+
return (out, inside) if mask else out
|
worldarray/retry.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Retrying a data read on a transient failure.
|
|
2
|
+
|
|
3
|
+
mesh-n-bone's policy, which its tests pin: attempt ``k`` (from 1) waits ``timeout * k``
|
|
4
|
+
seconds; ``timeout`` is given, or sized from the read and where it comes from:
|
|
5
|
+
``clamp(MiB / 16, 5, 120)`` for a remote store and ``clamp(MiB / 256, 5, 30)`` for a local
|
|
6
|
+
one. Between attempts the sleep is ``base_delay * 1.3 ** (k - 1)`` plus up to one second of
|
|
7
|
+
jitter. A timed-out future is cancelled before the next attempt (spec, Behaviour 6).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_TRANSIENT_PREFIXES = ("UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED")
|
|
23
|
+
_HTTP_TRANSIENT = re.compile(r"HTTP (?:5\d\d|429)\b")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Retry:
|
|
28
|
+
max_attempts: int = 10
|
|
29
|
+
timeout: float | None = None # seconds per attempt before scaling; None: from the read's size
|
|
30
|
+
base_delay: float = 1.0
|
|
31
|
+
|
|
32
|
+
def timeout_for(self, nbytes: int, remote: bool) -> float:
|
|
33
|
+
if self.timeout is not None:
|
|
34
|
+
return float(self.timeout)
|
|
35
|
+
mib = nbytes / 2**20
|
|
36
|
+
return min(120.0, max(5.0, mib / 16.0)) if remote else min(30.0, max(5.0, mib / 256.0))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_transient(error: BaseException) -> bool:
|
|
40
|
+
if isinstance(error, TimeoutError):
|
|
41
|
+
return True
|
|
42
|
+
message = str(error)
|
|
43
|
+
return message.startswith(_TRANSIENT_PREFIXES) or bool(_HTTP_TRANSIENT.search(message))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_with_retries(view, retry: Retry, *, remote: bool, what: str = "") -> np.ndarray:
|
|
47
|
+
"""``view.read()`` as a numpy array, retried per ``retry``. ``view`` is a tensorstore
|
|
48
|
+
(or any object whose ``read()`` returns a future with ``result(timeout)`` and
|
|
49
|
+
``cancel()``)."""
|
|
50
|
+
nbytes = int(np.prod(view.shape, dtype=np.int64)) * np.dtype(view.dtype.numpy_dtype).itemsize
|
|
51
|
+
timeout = retry.timeout_for(nbytes, remote)
|
|
52
|
+
last: BaseException | None = None
|
|
53
|
+
for attempt in range(1, max(1, retry.max_attempts) + 1):
|
|
54
|
+
future = view.read()
|
|
55
|
+
try:
|
|
56
|
+
return future.result(timeout=timeout * attempt)
|
|
57
|
+
except TimeoutError as e:
|
|
58
|
+
future.cancel()
|
|
59
|
+
last = e
|
|
60
|
+
except Exception as e:
|
|
61
|
+
if not is_transient(e):
|
|
62
|
+
raise
|
|
63
|
+
last = e
|
|
64
|
+
if attempt < retry.max_attempts:
|
|
65
|
+
delay = retry.base_delay * 1.3 ** (attempt - 1) + random.random()
|
|
66
|
+
logger.warning(
|
|
67
|
+
"read %s failed on attempt %d/%d (%s); retrying in %.1fs",
|
|
68
|
+
what,
|
|
69
|
+
attempt,
|
|
70
|
+
retry.max_attempts,
|
|
71
|
+
str(last).splitlines()[0][:120],
|
|
72
|
+
delay,
|
|
73
|
+
)
|
|
74
|
+
time.sleep(delay)
|
|
75
|
+
assert last is not None
|
|
76
|
+
raise last
|