|
| 1 | +"""Sample cube values at 3D grid cell centers.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from typing import TYPE_CHECKING, Literal |
| 6 | + |
| 7 | +import numpy as np |
| 8 | + |
| 9 | +if TYPE_CHECKING: |
| 10 | + from xtgeo.cube.cube1 import Cube |
| 11 | + |
| 12 | + from .grid import Grid |
| 13 | + |
| 14 | + |
| 15 | +def sample_cube_to_grid( |
| 16 | + grid: Grid, |
| 17 | + cube: Cube, |
| 18 | + interpolation: Literal["nearest", "trilinear", "cubic", "catmull-rom"] = "nearest", |
| 19 | + outside_value: float = 0.0, |
| 20 | +) -> np.ma.MaskedArray: |
| 21 | + """Sample cube values at grid cell centers and return as a masked array. |
| 22 | +
|
| 23 | + Args: |
| 24 | + grid: The 3D grid whose cell centers define the sampling locations. |
| 25 | + cube: The seismic cube to sample values from. |
| 26 | + interpolation: ``"nearest"``, ``"trilinear"``, ``"cubic"`` or |
| 27 | + ``"catmull-rom"``. |
| 28 | + outside_value: Value for active grid cells outside the cube extent. |
| 29 | +
|
| 30 | + Returns: |
| 31 | + A masked array with shape (ncol, nrow, nlay) containing sampled values. |
| 32 | + """ |
| 33 | + valid = ("nearest", "trilinear", "cubic", "catmull-rom") |
| 34 | + if interpolation not in valid: |
| 35 | + raise ValueError( |
| 36 | + f"Invalid interpolation method '{interpolation}'. " |
| 37 | + f"Supported methods are {', '.join(repr(v) for v in valid)}." |
| 38 | + ) |
| 39 | + |
| 40 | + # Get grid cell center coordinates as masked arrays (ncol, nrow, nlay) |
| 41 | + xprop, yprop, zprop = grid.get_xyz(asmasked=True) |
| 42 | + xv = xprop.values |
| 43 | + yv = yprop.values |
| 44 | + zv = zprop.values |
| 45 | + |
| 46 | + # Combined mask: cells inactive in grid (always ndarray, not scalar bool) |
| 47 | + combined_mask = np.asarray(xv.mask | yv.mask | zv.mask, dtype=bool) |
| 48 | + |
| 49 | + # Transform (x, y, z) from world coordinates to the cube's local frame, |
| 50 | + # accounting for rotation around the cube origin. |
| 51 | + angle_rad = np.deg2rad(cube.rotation) |
| 52 | + cos_a = np.cos(angle_rad) |
| 53 | + sin_a = np.sin(angle_rad) |
| 54 | + |
| 55 | + dx = np.asarray(xv, dtype=np.float64) - cube.xori |
| 56 | + dy = np.asarray(yv, dtype=np.float64) - cube.yori |
| 57 | + |
| 58 | + # Local coordinates along cube inline (I) and crossline (J) axes |
| 59 | + local_x = dx * cos_a + dy * sin_a |
| 60 | + local_y = (-dx * sin_a + dy * cos_a) * cube.yflip |
| 61 | + |
| 62 | + # Fractional indices in cube space |
| 63 | + fi = local_x / cube.xinc |
| 64 | + fj = local_y / cube.yinc |
| 65 | + fk = (np.asarray(zv, dtype=np.float64) - cube.zori) / cube.zinc |
| 66 | + |
| 67 | + if interpolation == "nearest": |
| 68 | + return _sample_nearest(cube, fi, fj, fk, combined_mask, outside_value) |
| 69 | + |
| 70 | + if interpolation == "cubic": |
| 71 | + return _sample_cubic(cube, fi, fj, fk, combined_mask, outside_value) |
| 72 | + |
| 73 | + if interpolation == "catmull-rom": |
| 74 | + return _sample_catmull_rom(cube, fi, fj, fk, combined_mask, outside_value) |
| 75 | + |
| 76 | + return _sample_trilinear(cube, fi, fj, fk, combined_mask, outside_value) |
| 77 | + |
| 78 | + |
| 79 | +def _sample_nearest( |
| 80 | + cube: Cube, |
| 81 | + fi: np.ndarray, |
| 82 | + fj: np.ndarray, |
| 83 | + fk: np.ndarray, |
| 84 | + combined_mask: np.ndarray, |
| 85 | + outside_value: float = 0.0, |
| 86 | +) -> np.ma.MaskedArray: |
| 87 | + """Nearest-neighbor sampling.""" |
| 88 | + # Fill NaN (from masked/inactive grid cells) with 0 before int cast to |
| 89 | + # avoid RuntimeWarning; these cells are masked out regardless. |
| 90 | + ii = np.rint(np.nan_to_num(fi, nan=0.0)).astype(int) |
| 91 | + jj = np.rint(np.nan_to_num(fj, nan=0.0)).astype(int) |
| 92 | + kk = np.rint(np.nan_to_num(fk, nan=0.0)).astype(int) |
| 93 | + |
| 94 | + outside = ( |
| 95 | + (ii < 0) |
| 96 | + | (ii >= cube.ncol) |
| 97 | + | (jj < 0) |
| 98 | + | (jj >= cube.nrow) |
| 99 | + | (kk < 0) |
| 100 | + | (kk >= cube.nlay) |
| 101 | + ) |
| 102 | + |
| 103 | + ii_safe = np.clip(ii, 0, cube.ncol - 1) |
| 104 | + jj_safe = np.clip(jj, 0, cube.nrow - 1) |
| 105 | + kk_safe = np.clip(kk, 0, cube.nlay - 1) |
| 106 | + |
| 107 | + sampled = cube.values[ii_safe, jj_safe, kk_safe].astype(np.float64) |
| 108 | + sampled[outside] = outside_value |
| 109 | + |
| 110 | + return np.ma.MaskedArray(sampled, mask=combined_mask, dtype=np.float64) |
| 111 | + |
| 112 | + |
| 113 | +def _sample_cubic( |
| 114 | + cube: Cube, |
| 115 | + fi: np.ndarray, |
| 116 | + fj: np.ndarray, |
| 117 | + fk: np.ndarray, |
| 118 | + combined_mask: np.ndarray, |
| 119 | + outside_value: float = 0.0, |
| 120 | +) -> np.ma.MaskedArray: |
| 121 | + """Tricubic spline interpolation via scipy.ndimage.map_coordinates.""" |
| 122 | + from scipy.ndimage import map_coordinates |
| 123 | + |
| 124 | + fi = np.nan_to_num(fi, nan=0.0) |
| 125 | + fj = np.nan_to_num(fj, nan=0.0) |
| 126 | + fk = np.nan_to_num(fk, nan=0.0) |
| 127 | + |
| 128 | + coords = np.array( |
| 129 | + [fi.ravel(), fj.ravel(), fk.ravel()], |
| 130 | + dtype=np.float64, |
| 131 | + ) |
| 132 | + |
| 133 | + sampled = map_coordinates( |
| 134 | + cube.values.astype(np.float64), |
| 135 | + coords, |
| 136 | + order=3, |
| 137 | + mode="constant", |
| 138 | + cval=outside_value, |
| 139 | + ).reshape(fi.shape) |
| 140 | + |
| 141 | + return np.ma.MaskedArray(sampled, mask=combined_mask, dtype=np.float64) |
| 142 | + |
| 143 | + |
| 144 | +def _catmull_rom_weights(t: np.ndarray) -> tuple[np.ndarray, ...]: |
| 145 | + """Catmull-Rom weights for offsets -1, 0, +1, +2 relative to floor index.""" |
| 146 | + t2 = t * t |
| 147 | + t3 = t2 * t |
| 148 | + w0 = -0.5 * t3 + t2 - 0.5 * t |
| 149 | + w1 = 1.5 * t3 - 2.5 * t2 + 1.0 |
| 150 | + w2 = -1.5 * t3 + 2.0 * t2 + 0.5 * t |
| 151 | + w3 = 0.5 * t3 - 0.5 * t2 |
| 152 | + return w0, w1, w2, w3 |
| 153 | + |
| 154 | + |
| 155 | +def _sample_catmull_rom( |
| 156 | + cube: Cube, |
| 157 | + fi: np.ndarray, |
| 158 | + fj: np.ndarray, |
| 159 | + fk: np.ndarray, |
| 160 | + combined_mask: np.ndarray, |
| 161 | + outside_value: float = 0.0, |
| 162 | +) -> np.ma.MaskedArray: |
| 163 | + """Tricubic Catmull-Rom interpolation (separable, vectorized). |
| 164 | +
|
| 165 | + Uses the Catmull-Rom cardinal spline kernel applied separably along each |
| 166 | + axis. This passes exactly through data points and is commonly used in |
| 167 | + seismic interpretation tools. |
| 168 | + """ |
| 169 | + fi = np.nan_to_num(fi, nan=0.0) |
| 170 | + fj = np.nan_to_num(fj, nan=0.0) |
| 171 | + fk = np.nan_to_num(fk, nan=0.0) |
| 172 | + |
| 173 | + i0 = np.floor(fi).astype(int) |
| 174 | + j0 = np.floor(fj).astype(int) |
| 175 | + k0 = np.floor(fk).astype(int) |
| 176 | + |
| 177 | + # Catmull-Rom needs indices floor-1 .. floor+2 in each dimension |
| 178 | + outside = ( |
| 179 | + (i0 - 1 < 0) |
| 180 | + | (i0 + 2 >= cube.ncol) |
| 181 | + | (j0 - 1 < 0) |
| 182 | + | (j0 + 2 >= cube.nrow) |
| 183 | + | (k0 - 1 < 0) |
| 184 | + | (k0 + 2 >= cube.nlay) |
| 185 | + ) |
| 186 | + |
| 187 | + wi = _catmull_rom_weights(fi - i0) |
| 188 | + wj = _catmull_rom_weights(fj - j0) |
| 189 | + wk = _catmull_rom_weights(fk - k0) |
| 190 | + |
| 191 | + nc, nr, nl = cube.ncol, cube.nrow, cube.nlay |
| 192 | + |
| 193 | + # Use a plain ndarray (not masked) and flat 1D indexing for speed. |
| 194 | + # np.ma.getdata strips the mask; np.ascontiguousarray ensures layout. |
| 195 | + cube_flat = np.ascontiguousarray( |
| 196 | + np.ma.getdata(cube.values), dtype=np.float64 |
| 197 | + ).ravel() |
| 198 | + stride_i = nr * nl |
| 199 | + stride_j = nl |
| 200 | + |
| 201 | + # Pre-compute clipped k-indices (same for all i, j offsets). |
| 202 | + k_idx = [np.clip(k0 + dk - 1, 0, nl - 1) for dk in range(4)] |
| 203 | + |
| 204 | + # Separable: k first (inlined), then j, then i. |
| 205 | + sampled = np.zeros(fi.shape, dtype=np.float64) |
| 206 | + |
| 207 | + for di in range(4): |
| 208 | + i_base = np.clip(i0 + di - 1, 0, nc - 1) * stride_i |
| 209 | + acc_j = np.zeros(fi.shape, dtype=np.float64) |
| 210 | + |
| 211 | + for dj in range(4): |
| 212 | + ij_base = i_base + np.clip(j0 + dj - 1, 0, nr - 1) * stride_j |
| 213 | + # Interpolate along k using flat 1D look-ups. |
| 214 | + val_k = ( |
| 215 | + wk[0] * cube_flat[ij_base + k_idx[0]] |
| 216 | + + wk[1] * cube_flat[ij_base + k_idx[1]] |
| 217 | + + wk[2] * cube_flat[ij_base + k_idx[2]] |
| 218 | + + wk[3] * cube_flat[ij_base + k_idx[3]] |
| 219 | + ) |
| 220 | + acc_j += wj[dj] * val_k |
| 221 | + |
| 222 | + sampled += wi[di] * acc_j |
| 223 | + |
| 224 | + sampled[outside] = outside_value |
| 225 | + |
| 226 | + return np.ma.MaskedArray(sampled, mask=combined_mask, dtype=np.float64) |
| 227 | + |
| 228 | + |
| 229 | +def _sample_trilinear( |
| 230 | + cube: Cube, |
| 231 | + fi: np.ndarray, |
| 232 | + fj: np.ndarray, |
| 233 | + fk: np.ndarray, |
| 234 | + combined_mask: np.ndarray, |
| 235 | + outside_value: float = 0.0, |
| 236 | +) -> np.ma.MaskedArray: |
| 237 | + """Trilinear interpolation sampling.""" |
| 238 | + # Fill NaN (from masked/inactive grid cells) with 0 before int cast to |
| 239 | + # avoid RuntimeWarning; these cells are masked out regardless. |
| 240 | + fi = np.nan_to_num(fi, nan=0.0) |
| 241 | + fj = np.nan_to_num(fj, nan=0.0) |
| 242 | + fk = np.nan_to_num(fk, nan=0.0) |
| 243 | + |
| 244 | + i0 = np.floor(fi).astype(int) |
| 245 | + j0 = np.floor(fj).astype(int) |
| 246 | + k0 = np.floor(fk).astype(int) |
| 247 | + |
| 248 | + di = fi - i0 |
| 249 | + dj = fj - j0 |
| 250 | + dk = fk - k0 |
| 251 | + |
| 252 | + i1 = i0 + 1 |
| 253 | + j1 = j0 + 1 |
| 254 | + k1 = k0 + 1 |
| 255 | + |
| 256 | + outside = ( |
| 257 | + (i0 < 0) |
| 258 | + | (i1 >= cube.ncol) |
| 259 | + | (j0 < 0) |
| 260 | + | (j1 >= cube.nrow) |
| 261 | + | (k0 < 0) |
| 262 | + | (k1 >= cube.nlay) |
| 263 | + ) |
| 264 | + |
| 265 | + i0s = np.clip(i0, 0, cube.ncol - 1) |
| 266 | + i1s = np.clip(i1, 0, cube.ncol - 1) |
| 267 | + j0s = np.clip(j0, 0, cube.nrow - 1) |
| 268 | + j1s = np.clip(j1, 0, cube.nrow - 1) |
| 269 | + k0s = np.clip(k0, 0, cube.nlay - 1) |
| 270 | + k1s = np.clip(k1, 0, cube.nlay - 1) |
| 271 | + |
| 272 | + cvals = cube.values |
| 273 | + c000 = cvals[i0s, j0s, k0s] |
| 274 | + c100 = cvals[i1s, j0s, k0s] |
| 275 | + c010 = cvals[i0s, j1s, k0s] |
| 276 | + c110 = cvals[i1s, j1s, k0s] |
| 277 | + c001 = cvals[i0s, j0s, k1s] |
| 278 | + c101 = cvals[i1s, j0s, k1s] |
| 279 | + c011 = cvals[i0s, j1s, k1s] |
| 280 | + c111 = cvals[i1s, j1s, k1s] |
| 281 | + |
| 282 | + sampled = ( |
| 283 | + c000 * (1 - di) * (1 - dj) * (1 - dk) |
| 284 | + + c100 * di * (1 - dj) * (1 - dk) |
| 285 | + + c010 * (1 - di) * dj * (1 - dk) |
| 286 | + + c110 * di * dj * (1 - dk) |
| 287 | + + c001 * (1 - di) * (1 - dj) * dk |
| 288 | + + c101 * di * (1 - dj) * dk |
| 289 | + + c011 * (1 - di) * dj * dk |
| 290 | + + c111 * di * dj * dk |
| 291 | + ) |
| 292 | + sampled[outside] = outside_value |
| 293 | + |
| 294 | + return np.ma.MaskedArray(sampled, mask=combined_mask, dtype=np.float64) |
0 commit comments