Skip to content

Commit e75e18c

Browse files
committed
ENH: add gridproperty_from_cube
1 parent b677cd5 commit e75e18c

File tree

4 files changed

+612
-0
lines changed

4 files changed

+612
-0
lines changed

src/xtgeo/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def _xprint(msg):
104104
)
105105
from xtgeo.grid3d.grid_property import (
106106
GridProperty,
107+
gridproperty_from_cube,
107108
gridproperty_from_file,
108109
gridproperty_from_roxar,
109110
)
@@ -221,6 +222,7 @@ def _xprint(msg):
221222
"grid_property",
222223
"gridproperties_dataframe",
223224
"gridproperties_from_file",
225+
"gridproperty_from_cube",
224226
"gridproperty_from_file",
225227
"gridproperty_from_roxar",
226228
"list_gridproperties",
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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)

src/xtgeo/grid3d/grid_property.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import numpy.typing as npt
4444

4545
from xtgeo.common.types import FileLike
46+
from xtgeo.cube.cube1 import Cube
4647
from xtgeo.xyz.polygons import Polygons
4748

4849
from ._gridprop_op1 import XYValueLists
@@ -194,6 +195,66 @@ def gridproperty_from_roxar(
194195
)
195196

196197

198+
def gridproperty_from_cube(
199+
grid: Grid,
200+
cube: Cube,
201+
name: str = "sampled_cube",
202+
interpolation: Literal["nearest", "trilinear", "cubic", "catmull-rom"] = "nearest",
203+
outside_value: float = 0.0,
204+
) -> GridProperty:
205+
"""Sample cube values at grid cell centers and return as a GridProperty.
206+
207+
This function samples values from a seismic cube at the (x, y, z)
208+
coordinates of each grid cell center. Grid cells that fall outside
209+
the cube extent are assigned ``outside_value``.
210+
211+
Args:
212+
grid: The 3D grid whose cell centers define the sampling locations.
213+
cube: The seismic cube to sample values from.
214+
name: Name of the resulting grid property.
215+
interpolation: Interpolation method:
216+
217+
- ``"nearest"``: nearest-neighbor (fastest, no smoothing, default)
218+
- ``"trilinear"``: trilinear interpolation using 8 surrounding nodes;
219+
smoother than `nearest` but still fast
220+
- ``"cubic"``: tricubic B-spline interpolation using 64 surrounding nodes;
221+
smoother than trilinear but somewhat slower
222+
- ``"catmull-rom"``: Catmull-Rom cardinal spline using 64 surrounding
223+
nodes; passes exactly through data points and is commonly used
224+
in seismic interpretation tools. This is a variant of cubic interpolation,
225+
and is somewhat slower than `cubic`.
226+
outside_value: Value assigned to active grid cells that are
227+
outside the cube extent. Default is 0.0.
228+
229+
Returns:
230+
A GridProperty instance with the sampled cube values.
231+
232+
Example::
233+
234+
import xtgeo
235+
236+
grid = xtgeo.grid_from_file("my_grid.roff")
237+
cube = xtgeo.cube_from_file("my_seismic.segy")
238+
seisprop = xtgeo.gridproperty_from_cube(grid, cube, name="seismic")
239+
240+
.. versionadded:: 4.19.0
241+
"""
242+
from ._gridprop_from_cube import sample_cube_to_grid
243+
244+
result = sample_cube_to_grid(
245+
grid, cube, interpolation=interpolation, outside_value=outside_value
246+
)
247+
248+
return GridProperty(
249+
ncol=grid.ncol,
250+
nrow=grid.nrow,
251+
nlay=grid.nlay,
252+
values=result,
253+
name=name,
254+
discrete=False,
255+
)
256+
257+
197258
class GridProperty(_Grid3D):
198259
"""
199260
Class for a single 3D grid property, e.g porosity or facies.

0 commit comments

Comments
 (0)