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/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