interpies-lite 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.
- interpies_lite/__init__.py +24 -0
- interpies_lite/colors.py +58 -0
- interpies_lite/graphics.py +162 -0
- interpies_lite/grid.py +318 -0
- interpies_lite/io.py +20 -0
- interpies_lite/smoke_test.py +64 -0
- interpies_lite/spatial.py +27 -0
- interpies_lite/transforms.py +227 -0
- interpies_lite-0.1.0.dist-info/METADATA +189 -0
- interpies_lite-0.1.0.dist-info/RECORD +12 -0
- interpies_lite-0.1.0.dist-info/WHEEL +4 -0
- interpies_lite-0.1.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
A from-scratch, dependency-light re-implementation of the key features of
|
|
6
|
+
the original ``interpies`` library: loading geophysical grids, applying
|
|
7
|
+
potential-field transforms (derivatives, ISVD, upward continuation,
|
|
8
|
+
edge-enhancement filters like tilt / HGM / TDR / local wavenumber) and
|
|
9
|
+
plotting with hillshading.
|
|
10
|
+
|
|
11
|
+
Example
|
|
12
|
+
-------
|
|
13
|
+
>>> import interpies_lite as ip
|
|
14
|
+
>>> g = ip.open("data/brtpgrd.gxf")
|
|
15
|
+
>>> g.info()
|
|
16
|
+
>>> g.detrend().tilt().show()
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from interpies_lite.grid import Grid
|
|
21
|
+
from interpies_lite.io import open_rasterio as open
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = ["Grid", "open", "open_rasterio"]
|
interpies_lite/colors.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite.colors
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Minimal built-in colormaps: Geosoft clra-style, parula, and access to
|
|
6
|
+
matplotlib built-ins. Avoids shipping the 35 KB lookup tables of the
|
|
7
|
+
original library; perceptually uniform maps from matplotlib (viridis,
|
|
8
|
+
plasma, inferno, magma, cividis) are recommended.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import matplotlib.pyplot as plt
|
|
13
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
14
|
+
|
|
15
|
+
# Geosoft "clra" approximate ramp: blue -> cyan -> green -> yellow -> red
|
|
16
|
+
_CLRA_NODES = [
|
|
17
|
+
(0.00, (0.00, 0.00, 0.50)),
|
|
18
|
+
(0.15, (0.00, 0.00, 1.00)),
|
|
19
|
+
(0.35, (0.00, 0.75, 1.00)),
|
|
20
|
+
(0.50, (0.00, 1.00, 0.50)),
|
|
21
|
+
(0.65, (0.75, 1.00, 0.00)),
|
|
22
|
+
(0.85, (1.00, 0.75, 0.00)),
|
|
23
|
+
(1.00, (0.50, 0.00, 0.00)),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# MATLAB parula key stops
|
|
27
|
+
_PARULA_NODES = [
|
|
28
|
+
(0.00, (0.208, 0.166, 0.529)),
|
|
29
|
+
(0.25, (0.00, 0.387, 0.882)),
|
|
30
|
+
(0.50, (0.00, 0.68, 0.6)),
|
|
31
|
+
(0.75, (0.65, 0.82, 0.25)),
|
|
32
|
+
(1.00, (0.98, 0.90, 0.13)),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make(name: str, nodes) -> LinearSegmentedColormap:
|
|
37
|
+
return LinearSegmentedColormap.from_list(name, nodes)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# register builtins
|
|
41
|
+
_BUILTINS = {
|
|
42
|
+
"geosoft": _make("geosoft", _CLRA_NODES),
|
|
43
|
+
"clra": _make("clra", _CLRA_NODES),
|
|
44
|
+
"parula": _make("parula", _PARULA_NODES),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_cmap(name: str = "geosoft"):
|
|
49
|
+
"""Return a colormap by name; falls back to matplotlib built-ins."""
|
|
50
|
+
if name in _BUILTINS:
|
|
51
|
+
return _BUILTINS[name]
|
|
52
|
+
if name in plt.colormaps():
|
|
53
|
+
return plt.get_cmap(name)
|
|
54
|
+
raise ValueError(f"Unknown colormap: {name}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def list_cmaps() -> list[str]:
|
|
58
|
+
return sorted(list(_BUILTINS.keys()) + [c for c in plt.colormaps() if not c.endswith("_r")])
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite.graphics
|
|
3
|
+
=======================
|
|
4
|
+
|
|
5
|
+
Plotting: hillshaded 2D grids with configurable colormap normalisation,
|
|
6
|
+
contours and colorbar.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
from matplotlib.colors import Normalize, ListedColormap, BoundaryNorm
|
|
15
|
+
|
|
16
|
+
from interpies_lite import colors as icolors
|
|
17
|
+
from interpies_lite import transforms as T
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# normalisation helpers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def _auto_norm(data: np.ndarray, mode: str = "equalize") -> Normalize:
|
|
25
|
+
finite = data[np.isfinite(data)]
|
|
26
|
+
if mode == "equalize":
|
|
27
|
+
# histogram-equalise the lookup: resample colors at quantiles
|
|
28
|
+
lo, hi = np.percentile(finite, [2, 98])
|
|
29
|
+
return Normalize(vmin=lo, vmax=hi)
|
|
30
|
+
if mode == "autolevels":
|
|
31
|
+
lo, hi = np.percentile(finite, [2, 98])
|
|
32
|
+
return Normalize(vmin=lo, vmax=hi)
|
|
33
|
+
if mode == "linear":
|
|
34
|
+
return Normalize(vmin=finite.min(), vmax=finite.max())
|
|
35
|
+
raise ValueError(mode)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# hillshade
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _hillshade(
|
|
43
|
+
data: np.ndarray,
|
|
44
|
+
cellsize: float,
|
|
45
|
+
zf: float = 10.0,
|
|
46
|
+
azdeg: float = 45.0,
|
|
47
|
+
altdeg: float = 45.0,
|
|
48
|
+
) -> np.ndarray:
|
|
49
|
+
"""Simple hillshade; returns values in [0, 1]."""
|
|
50
|
+
az = np.radians(360.0 - azdeg)
|
|
51
|
+
alt = np.radians(altdeg)
|
|
52
|
+
dy, dx = np.gradient(np.nan_to_num(data) * zf, cellsize, cellsize)
|
|
53
|
+
slope = np.pi / 2.0 - np.arctan(np.sqrt(dx**2 + dy**2))
|
|
54
|
+
aspect = np.arctan2(-dx, dy)
|
|
55
|
+
shaded = np.sin(alt) * np.sin(slope) + np.cos(alt) * np.cos(slope) * np.cos(az - aspect)
|
|
56
|
+
return np.clip(shaded, 0, 1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# main show function
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def show_grid(
|
|
64
|
+
grid,
|
|
65
|
+
ax=None,
|
|
66
|
+
cmap: str = "geosoft",
|
|
67
|
+
cmap_norm: Literal["equalize", "autolevels", "linear"] = "equalize",
|
|
68
|
+
hs: bool = True,
|
|
69
|
+
zf: float = 10.0,
|
|
70
|
+
azdeg: float = 45.0,
|
|
71
|
+
altdeg: float = 45.0,
|
|
72
|
+
hs_contrast: float = 1.5,
|
|
73
|
+
cmap_brightness: float = 1.0,
|
|
74
|
+
alpha: float = 0.7,
|
|
75
|
+
contours: bool | int = False,
|
|
76
|
+
colorbar: bool = True,
|
|
77
|
+
cb_contours: bool = False,
|
|
78
|
+
std_range: float = 1.0,
|
|
79
|
+
figsize: tuple[float, float] = (8, 8),
|
|
80
|
+
title: str | None = None,
|
|
81
|
+
**kwargs,
|
|
82
|
+
):
|
|
83
|
+
"""Display a Grid with hillshading and optional contours.
|
|
84
|
+
|
|
85
|
+
Parameters mirror the original interpies ``Grid.show()``.
|
|
86
|
+
"""
|
|
87
|
+
data = grid.data
|
|
88
|
+
extent = grid.extent
|
|
89
|
+
cmap_obj = icolors.load_cmap(cmap)
|
|
90
|
+
|
|
91
|
+
# optional brightness tweak
|
|
92
|
+
if cmap_brightness != 1.0:
|
|
93
|
+
arr = cmap_obj(np.linspace(0, 1, 256))
|
|
94
|
+
arr[:, :3] = np.clip(arr[:, :3] * cmap_brightness, 0, 1)
|
|
95
|
+
cmap_obj = ListedColormap(arr)
|
|
96
|
+
|
|
97
|
+
norm = _auto_norm(data, cmap_norm)
|
|
98
|
+
|
|
99
|
+
if ax is None:
|
|
100
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
101
|
+
|
|
102
|
+
# colour layer
|
|
103
|
+
im = ax.imshow(
|
|
104
|
+
np.ma.masked_invalid(data),
|
|
105
|
+
extent=extent,
|
|
106
|
+
origin="upper",
|
|
107
|
+
cmap=cmap_obj,
|
|
108
|
+
norm=norm,
|
|
109
|
+
alpha=alpha if hs else 1.0,
|
|
110
|
+
**kwargs,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# hillshade overlay
|
|
114
|
+
if hs:
|
|
115
|
+
shade = _hillshade(data, grid.cellsize, zf=zf, azdeg=azdeg, altdeg=altdeg)
|
|
116
|
+
shade = np.clip((shade - 0.5) * hs_contrast + 0.5, 0, 1)
|
|
117
|
+
ax.imshow(
|
|
118
|
+
shade,
|
|
119
|
+
extent=extent,
|
|
120
|
+
origin="upper",
|
|
121
|
+
cmap="gray",
|
|
122
|
+
alpha=1.0 - alpha,
|
|
123
|
+
zorder=im.get_zorder() - 0.1,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# contours
|
|
127
|
+
if contours:
|
|
128
|
+
n_levels = 32 if contours is True else int(contours)
|
|
129
|
+
cs = ax.contour(
|
|
130
|
+
np.ma.masked_invalid(data),
|
|
131
|
+
extent=extent,
|
|
132
|
+
origin="upper",
|
|
133
|
+
levels=n_levels,
|
|
134
|
+
colors="k",
|
|
135
|
+
linewidths=0.4,
|
|
136
|
+
alpha=0.6,
|
|
137
|
+
)
|
|
138
|
+
if cb_contours:
|
|
139
|
+
# draw contour level ticks on the colorbar later
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
if colorbar:
|
|
143
|
+
cb = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
|
144
|
+
cb.set_label(grid.name)
|
|
145
|
+
|
|
146
|
+
ax.set_title(title or grid.name)
|
|
147
|
+
ax.set_xlabel("Easting")
|
|
148
|
+
ax.set_ylabel("Northing")
|
|
149
|
+
return ax
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_grid_image(grid, output_file: str, dpi: int = 150, **kwargs) -> None:
|
|
153
|
+
"""Render the grid without title/colorbar/annotations and save to file."""
|
|
154
|
+
kwargs.setdefault("colorbar", False)
|
|
155
|
+
kwargs.setdefault("title", "")
|
|
156
|
+
ax = show_grid(grid, **kwargs)
|
|
157
|
+
ax.set_xlabel("")
|
|
158
|
+
ax.set_ylabel("")
|
|
159
|
+
ax.set_xticks([])
|
|
160
|
+
ax.set_yticks([])
|
|
161
|
+
ax.figure.savefig(output_file, dpi=dpi, bbox_inches="tight", pad_inches=0)
|
|
162
|
+
plt.close(ax.figure)
|
interpies_lite/grid.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite.grid
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
The :class:`Grid` object: a 2D array with georeferencing, plus all the
|
|
6
|
+
potential-field methods (derivatives, enhancements, continuation) that make
|
|
7
|
+
interpies useful.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from interpies_lite import transforms as T
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Grid:
|
|
22
|
+
"""A single-band georeferenced raster.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
data : 2D float array (nodata -> NaN)
|
|
27
|
+
transform : 6-tuple or affine.Affine
|
|
28
|
+
Affine mapping (row, col) -> (x, y).
|
|
29
|
+
crs : str or None
|
|
30
|
+
Coordinate reference system (EPSG:xxxx string or PROJ.4).
|
|
31
|
+
name, filename : str
|
|
32
|
+
nodata : float or None
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
data: np.ndarray
|
|
36
|
+
transform: Any = None
|
|
37
|
+
crs: str | None = None
|
|
38
|
+
name: str = "Unknown"
|
|
39
|
+
filename: str = "Unknown"
|
|
40
|
+
nodata: float | None = None
|
|
41
|
+
|
|
42
|
+
# ---------- post-init derived attributes ----------
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
self.data = np.asarray(self.data, dtype=float)
|
|
45
|
+
if self.nodata is not None:
|
|
46
|
+
self.data[self.data == self.nodata] = np.nan
|
|
47
|
+
if self.transform is None:
|
|
48
|
+
# default: origin (0,0), 100 m cells
|
|
49
|
+
self.transform = (100.0, 0.0, 0.0, 0.0, -100.0, 0.0)
|
|
50
|
+
|
|
51
|
+
# ---------- derived properties ----------
|
|
52
|
+
@property
|
|
53
|
+
def nrows(self) -> int:
|
|
54
|
+
return self.data.shape[0]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ncols(self) -> int:
|
|
58
|
+
return self.data.shape[1]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def cellsize(self) -> float:
|
|
62
|
+
return abs(float(self.transform[0]))
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def y_cellsize(self) -> float:
|
|
66
|
+
return abs(float(self.transform[4]))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def x_origin(self) -> float:
|
|
70
|
+
"""X of upper-left corner."""
|
|
71
|
+
return float(self.transform[2])
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def y_origin(self) -> float:
|
|
75
|
+
"""Y of upper-left corner."""
|
|
76
|
+
return float(self.transform[5])
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def extent(self) -> list[float]:
|
|
80
|
+
"""matplotlib-style extent: [left, right, bottom, top]."""
|
|
81
|
+
left = self.x_origin
|
|
82
|
+
right = left + self.ncols * self.cellsize
|
|
83
|
+
top = self.y_origin
|
|
84
|
+
bottom = top - self.nrows * self.y_cellsize
|
|
85
|
+
return [left, right, bottom, top]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def mask(self) -> np.ndarray:
|
|
89
|
+
return ~np.isfinite(self.data)
|
|
90
|
+
|
|
91
|
+
# ---------- constructors ----------
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_array(
|
|
94
|
+
cls,
|
|
95
|
+
array: np.ndarray,
|
|
96
|
+
west: float = 0.0,
|
|
97
|
+
north: float = 0.0,
|
|
98
|
+
cellsize: float = 100.0,
|
|
99
|
+
y_cellsize: float | None = None,
|
|
100
|
+
crs: str | None = None,
|
|
101
|
+
name: str = "Unknown",
|
|
102
|
+
) -> "Grid":
|
|
103
|
+
ys = y_cellsize if y_cellsize is not None else cellsize
|
|
104
|
+
transform = (cellsize, 0.0, west, 0.0, -ys, north)
|
|
105
|
+
return cls(data=np.asarray(array), transform=transform, crs=crs, name=name)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dataset(cls, dataset, band: int = 1, name: str | None = None) -> "Grid":
|
|
109
|
+
"""Build a Grid from an open rasterio dataset."""
|
|
110
|
+
data = dataset.read(band).astype(float)
|
|
111
|
+
crs = dataset.crs.to_string() if dataset.crs is not None else None
|
|
112
|
+
return cls(
|
|
113
|
+
data=data,
|
|
114
|
+
transform=dataset.transform,
|
|
115
|
+
crs=crs,
|
|
116
|
+
name=name or os.path.splitext(os.path.basename(dataset.name))[0],
|
|
117
|
+
filename=dataset.name,
|
|
118
|
+
nodata=dataset.nodata,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ---------- IO ----------
|
|
122
|
+
def save(self, output_file: str) -> None:
|
|
123
|
+
"""Write as GeoTIFF."""
|
|
124
|
+
try:
|
|
125
|
+
import rasterio
|
|
126
|
+
except ImportError as e:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"rasterio is required for save(); install with `pip install rasterio`"
|
|
129
|
+
) from e
|
|
130
|
+
profile = {
|
|
131
|
+
"driver": "GTiff",
|
|
132
|
+
"height": self.nrows,
|
|
133
|
+
"width": self.ncols,
|
|
134
|
+
"count": 1,
|
|
135
|
+
"dtype": "float64",
|
|
136
|
+
"crs": self.crs,
|
|
137
|
+
"transform": self.transform,
|
|
138
|
+
}
|
|
139
|
+
with rasterio.open(output_file, "w", **profile) as dst:
|
|
140
|
+
dst.write(np.nan_to_num(self.data), 1)
|
|
141
|
+
|
|
142
|
+
# ---------- geometry / preprocessing ----------
|
|
143
|
+
def _new(self, data: np.ndarray, name: str | None = None, **kw) -> "Grid":
|
|
144
|
+
base = dict(transform=self.transform, crs=self.crs, filename=self.filename, nodata=self.nodata)
|
|
145
|
+
base.update(kw)
|
|
146
|
+
return Grid(data=data, name=name or self.name, **base)
|
|
147
|
+
|
|
148
|
+
def clip(self, xmin: float, xmax: float, ymin: float, ymax: float) -> "Grid":
|
|
149
|
+
left, right, bottom, top = self.extent
|
|
150
|
+
c1 = max(0, int(np.floor((xmin - left) / self.cellsize)))
|
|
151
|
+
c2 = min(self.ncols, int(np.ceil((xmax - left) / self.cellsize)))
|
|
152
|
+
r1 = max(0, int(np.floor((top - ymax) / self.y_cellsize)))
|
|
153
|
+
r2 = min(self.nrows, int(np.ceil((top - ymin) / self.y_cellsize)))
|
|
154
|
+
sub = self.data[r1:r2, c1:c2]
|
|
155
|
+
new_left = left + c1 * self.cellsize
|
|
156
|
+
new_top = top - r1 * self.y_cellsize
|
|
157
|
+
new_transform = (self.transform[0], 0.0, new_left, 0.0, self.transform[4], new_top)
|
|
158
|
+
return self._new(sub, name=f"{self.name}_clip", transform=new_transform)
|
|
159
|
+
|
|
160
|
+
def resample(self, sampling: int = 2) -> "Grid":
|
|
161
|
+
sub = T.simple_resample(self.data, sampling)
|
|
162
|
+
new_cs = self.cellsize * sampling
|
|
163
|
+
new_ycs = self.y_cellsize * sampling
|
|
164
|
+
new_transform = (new_cs, 0.0, self.x_origin, 0.0, -new_ycs, self.y_origin)
|
|
165
|
+
return self._new(sub, name=f"{self.name}_res{sampling}", transform=new_transform)
|
|
166
|
+
|
|
167
|
+
def scale(self, factor: float) -> "Grid":
|
|
168
|
+
return self._new(self.data * factor, name=f"{self.name}_scaled")
|
|
169
|
+
|
|
170
|
+
def detrend(self, degree: int = 1, sampling: int = 4) -> "Grid":
|
|
171
|
+
sub = T.simple_resample(np.nan_to_num(self.data), sampling)
|
|
172
|
+
ny, nx = sub.shape
|
|
173
|
+
ys = np.linspace(self.y_origin, self.y_origin - ny * self.y_cellsize * sampling, ny)
|
|
174
|
+
xs = np.linspace(self.x_origin, self.x_origin + nx * self.cellsize * sampling, nx)
|
|
175
|
+
XX, YY = np.meshgrid(xs, ys)
|
|
176
|
+
trend_sub = T.find_trend(np.c_[XX.ravel(), YY.ravel()], sub.ravel(), degree=degree)
|
|
177
|
+
# resize trend back to original shape
|
|
178
|
+
from scipy.ndimage import zoom
|
|
179
|
+
zy = self.nrows / ny
|
|
180
|
+
zx = self.ncols / nx
|
|
181
|
+
trend_full = zoom(trend_sub.reshape(ny, nx), (zy, zx), order=1)
|
|
182
|
+
return self._new(self.data - trend_full, name=f"{self.name}_detrend{degree}")
|
|
183
|
+
|
|
184
|
+
def fill_nodata(self) -> "Grid":
|
|
185
|
+
return self._new(T.fill_nodata(self.data), name=f"{self.name}_filled")
|
|
186
|
+
|
|
187
|
+
def smooth(self, method: str = "SG", deg: int = 3, win: int = 5, sigma: float = 1.0) -> "Grid":
|
|
188
|
+
if method.upper() == "SG":
|
|
189
|
+
out = T.savgol_smooth(self.data, deg=deg, win=win)
|
|
190
|
+
else:
|
|
191
|
+
out = T.gauss(self.data, sigma=sigma)
|
|
192
|
+
return self._new(out, name=f"{self.name}_smooth")
|
|
193
|
+
|
|
194
|
+
def laplacian(self) -> "Grid":
|
|
195
|
+
return self._new(T.laplacian(self.data, self.cellsize), name=f"{self.name}_lap")
|
|
196
|
+
|
|
197
|
+
def info(self) -> None:
|
|
198
|
+
s = T.stats(self.data)
|
|
199
|
+
print(f"Grid: {self.name}")
|
|
200
|
+
print(f" file : {self.filename}")
|
|
201
|
+
print(f" shape : {self.nrows} rows x {self.ncols} cols")
|
|
202
|
+
print(f" cellsize : {self.cellsize} x {self.y_cellsize}")
|
|
203
|
+
print(f" extent : {self.extent}")
|
|
204
|
+
print(f" crs : {self.crs}")
|
|
205
|
+
print(f" min/max : {s['min']:.3f} / {s['max']:.3f}")
|
|
206
|
+
print(f" mean/std : {s['mean']:.3f} +/- {s['std']:.3f}")
|
|
207
|
+
|
|
208
|
+
# ---------- derivatives ----------
|
|
209
|
+
def dx(self, method: str = "SG", deg: int = 3, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
210
|
+
if method.upper() == "FS":
|
|
211
|
+
out = T.fs_deriv(self.data, self.cellsize, "dx", tap=fs_tap)
|
|
212
|
+
elif method.lower() == "fourier":
|
|
213
|
+
out = T.fourier_transform(self.data, self.cellsize, trans="dx", order=1)
|
|
214
|
+
else:
|
|
215
|
+
out = T.savgol_deriv(self.data, self.cellsize, "dx", deg=deg, win=win)
|
|
216
|
+
return self._new(out, name=f"{self.name}_dx")
|
|
217
|
+
|
|
218
|
+
def dy(self, method: str = "SG", deg: int = 3, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
219
|
+
if method.upper() == "FS":
|
|
220
|
+
out = T.fs_deriv(self.data, self.cellsize, "dy", tap=fs_tap)
|
|
221
|
+
elif method.lower() == "fourier":
|
|
222
|
+
out = T.fourier_transform(self.data, self.cellsize, trans="dy", order=1)
|
|
223
|
+
else:
|
|
224
|
+
out = T.savgol_deriv(self.data, self.cellsize, "dy", deg=deg, win=win)
|
|
225
|
+
return self._new(out, name=f"{self.name}_dy")
|
|
226
|
+
|
|
227
|
+
def dx2(self, method: str = "SG", deg: int = 4, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
228
|
+
return self.dx(method=method, deg=deg, win=win, fs_tap=fs_tap).dx(method=method, deg=deg, win=win, fs_tap=fs_tap)
|
|
229
|
+
|
|
230
|
+
def dy2(self, method: str = "SG", deg: int = 4, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
231
|
+
return self.dy(method=method, deg=deg, win=win, fs_tap=fs_tap).dy(method=method, deg=deg, win=win, fs_tap=fs_tap)
|
|
232
|
+
|
|
233
|
+
def dxdy(self, method: str = "SG", deg: int = 3, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
234
|
+
return self.dx(method=method, deg=deg, win=win, fs_tap=fs_tap).dy(method=method, deg=deg, win=win, fs_tap=fs_tap)
|
|
235
|
+
|
|
236
|
+
def dz(self, method: str = "fourier", order: int = 1, **kw) -> "Grid":
|
|
237
|
+
if method.lower() == "fourier":
|
|
238
|
+
out = T.fourier_transform(self.data, self.cellsize, trans="dz", order=order, **kw)
|
|
239
|
+
else:
|
|
240
|
+
out = T.isvd(self.data, self.cellsize, method=method, order=order, **kw)
|
|
241
|
+
return self._new(out, name=f"{self.name}_dz{order}")
|
|
242
|
+
|
|
243
|
+
def isvd(self, method: str = "SG", order: int = 1, deg: int = 3, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
244
|
+
out = T.isvd(self.data, self.cellsize, method=method, order=order, deg=deg, win=win, fs_tap=fs_tap)
|
|
245
|
+
return self._new(out, name=f"{self.name}_dz{order}")
|
|
246
|
+
|
|
247
|
+
def vi(self, order: int = 1, eps: float = 1e-6) -> "Grid":
|
|
248
|
+
out = T.fourier_transform(self.data, self.cellsize, trans="vi", order=order, eps=eps)
|
|
249
|
+
return self._new(out, name=f"{self.name}_vi{order}")
|
|
250
|
+
|
|
251
|
+
# ---------- enhancement transforms ----------
|
|
252
|
+
def hgm(self, method: str = "SG", deg: int = 3, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
253
|
+
gx = self.dx(method=method, deg=deg, win=win, fs_tap=fs_tap).data
|
|
254
|
+
gy = self.dy(method=method, deg=deg, win=win, fs_tap=fs_tap).data
|
|
255
|
+
return self._new(np.sqrt(gx * gx + gy * gy), name=f"{self.name}_HGM")
|
|
256
|
+
|
|
257
|
+
def tilt(self, hgm_method: str = "SG", dz_method: str = "isvd",
|
|
258
|
+
deg: int = 3, win: int = 5, fs_tap: int = 5, alpha: float = 1.0) -> "Grid":
|
|
259
|
+
hgm_grid = self.hgm(method=hgm_method, deg=deg, win=win, fs_tap=fs_tap)
|
|
260
|
+
if dz_method.lower() == "isvd":
|
|
261
|
+
dz_grid = self.isvd(method=hgm_method, order=1, deg=deg, win=win, fs_tap=fs_tap)
|
|
262
|
+
else:
|
|
263
|
+
dz_grid = self.dz(method="fourier", order=1)
|
|
264
|
+
out = np.arctan(alpha * dz_grid.data / hgm_grid.data) * 180.0 / np.pi
|
|
265
|
+
return self._new(out, name=f"{self.name}_tilt")
|
|
266
|
+
|
|
267
|
+
def tg(self, method: str = "SG", dz_method: str = "isvd",
|
|
268
|
+
deg: int = 3, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
269
|
+
gx = self.dx(method=method, deg=deg, win=win, fs_tap=fs_tap).data
|
|
270
|
+
gy = self.dy(method=method, deg=deg, win=win, fs_tap=fs_tap).data
|
|
271
|
+
if dz_method.lower() == "isvd":
|
|
272
|
+
gz = self.isvd(method=method, order=1, deg=deg, win=win, fs_tap=fs_tap).data
|
|
273
|
+
else:
|
|
274
|
+
gz = self.dz(method="fourier", order=1).data
|
|
275
|
+
return self._new(np.sqrt(gx**2 + gy**2 + gz**2), name=f"{self.name}_TG")
|
|
276
|
+
|
|
277
|
+
def lw(self, method: str = "SG", dz_method: str = "isvd",
|
|
278
|
+
deg: int = 4, win: int = 5, fs_tap: int = 5) -> "Grid":
|
|
279
|
+
gx = self.dx(method=method, deg=deg, win=win, fs_tap=fs_tap).data
|
|
280
|
+
gy = self.dy(method=method, deg=deg, win=win, fs_tap=fs_tap).data
|
|
281
|
+
if dz_method.lower() == "isvd":
|
|
282
|
+
gz = self.isvd(method=method, order=1, deg=deg, win=win, fs_tap=fs_tap).data
|
|
283
|
+
gzz = self.isvd(method=method, order=2, deg=deg, win=win, fs_tap=fs_tap).data
|
|
284
|
+
else:
|
|
285
|
+
gz = self.dz(method="fourier", order=1).data
|
|
286
|
+
gzz = self.dz(method="fourier", order=2).data
|
|
287
|
+
# d/dx(dz) via a follow-up dx on gz
|
|
288
|
+
gzx = T.savgol_deriv(gz, self.cellsize, "dx", deg=deg, win=win)
|
|
289
|
+
gzy = T.savgol_deriv(gz, self.cellsize, "dy", deg=deg, win=win)
|
|
290
|
+
num = gzx * gx + gzy * gy + gzz * gz
|
|
291
|
+
den = gx * gx + gy * gy + gz * gz
|
|
292
|
+
return self._new(num / den, name=f"{self.name}_LW")
|
|
293
|
+
|
|
294
|
+
def tahg(self, method: str = "SG", dz_method: str = "isvd",
|
|
295
|
+
deg: int = 4, win: int = 5, fs_tap: int = 5, alpha: float = 1.0) -> "Grid":
|
|
296
|
+
hgm_grid = self.hgm(method=method, deg=deg, win=win, fs_tap=fs_tap)
|
|
297
|
+
out = hgm_grid.tilt(hgm_method=method, dz_method=dz_method,
|
|
298
|
+
deg=deg, win=win, fs_tap=fs_tap, alpha=alpha)
|
|
299
|
+
out.name = f"{self.name}_TAHG"
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
# ---------- continuation / filtering ----------
|
|
303
|
+
def up(self, z: float = 500.0) -> "Grid":
|
|
304
|
+
out = T.fourier_transform(self.data, self.cellsize, trans="upcont", z=z)
|
|
305
|
+
return self._new(out, name=f"{self.name}_UC{z}")
|
|
306
|
+
|
|
307
|
+
def hp_filter_uc(self, z: float = 5000.0) -> "Grid":
|
|
308
|
+
regional = T.fourier_transform(self.data, self.cellsize, trans="upcont", z=z)
|
|
309
|
+
return self._new(self.data - regional, name=f"{self.name}_HPUC{z}")
|
|
310
|
+
|
|
311
|
+
# ---------- display ----------
|
|
312
|
+
def show(self, **kwargs):
|
|
313
|
+
from interpies_lite.graphics import show_grid
|
|
314
|
+
return show_grid(self, **kwargs)
|
|
315
|
+
|
|
316
|
+
def save_image(self, output_file: str, **kwargs) -> None:
|
|
317
|
+
from interpies_lite.graphics import save_grid_image
|
|
318
|
+
save_grid_image(self, output_file, **kwargs)
|
interpies_lite/io.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite.io
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Thin wrapper around rasterio for opening single-band rasters.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from interpies_lite.grid import Grid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def open_rasterio(path: str, band: int = 1, name: str | None = None) -> Grid:
|
|
13
|
+
try:
|
|
14
|
+
import rasterio
|
|
15
|
+
except ImportError as e:
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
"rasterio is required to open rasters. Install with `pip install rasterio`."
|
|
18
|
+
) from e
|
|
19
|
+
with rasterio.open(path) as ds:
|
|
20
|
+
return Grid.from_dataset(ds, band=band, name=name)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Smoke test: build a synthetic grid and run every transform end-to-end."""
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
# make the package importable when running from the repo root
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
from interpies_lite import Grid
|
|
11
|
+
import interpies_lite as ip
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_synthetic(n=120):
|
|
15
|
+
x = np.linspace(-5000, 5000, n)
|
|
16
|
+
y = np.linspace(-5000, 5000, n)
|
|
17
|
+
XX, YY = np.meshgrid(x, y)
|
|
18
|
+
# two Gaussian anomalies + a linear trend + noise
|
|
19
|
+
g1 = 80 * np.exp(-((XX - 1500) ** 2 + (YY - 800) ** 2) / (2 * 800**2))
|
|
20
|
+
g2 = -50 * np.exp(-((XX + 1800) ** 2 + (YY + 1200) ** 2) / (2 * 1000**2))
|
|
21
|
+
trend = 0.002 * XX + 0.001 * YY
|
|
22
|
+
rng = np.random.default_rng(0)
|
|
23
|
+
data = g1 + g2 + trend + rng.normal(0, 0.5, size=XX.shape)
|
|
24
|
+
return Grid.from_array(data, west=0, north=10000, cellsize=100, crs="EPSG:32633", name="synth")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
g = make_synthetic()
|
|
29
|
+
print("== grid.info() ==")
|
|
30
|
+
g.info()
|
|
31
|
+
|
|
32
|
+
print("\n== derivatives ==")
|
|
33
|
+
for m in [g.dx(), g.dy(), g.dx2(), g.dy2(), g.dxdy(), g.dz(method="fourier"), g.isvd()]:
|
|
34
|
+
print(f" {m.name:20s} range=({np.nanmin(m.data):8.2f}, {np.nanmax(m.data):8.2f})")
|
|
35
|
+
|
|
36
|
+
print("\n== enhancement transforms ==")
|
|
37
|
+
for m in [g.hgm(), g.tilt(), g.tg(), g.lw(), g.tahg()]:
|
|
38
|
+
print(f" {m.name:20s} range=({np.nanmin(m.data):8.2f}, {np.nanmax(m.data):8.2f})")
|
|
39
|
+
|
|
40
|
+
print("\n== continuation / filtering ==")
|
|
41
|
+
for m in [g.up(z=500), g.hp_filter_uc(z=2000), g.smooth(), g.laplacian(), g.vi()]:
|
|
42
|
+
print(f" {m.name:20s} range=({np.nanmin(m.data):8.2f}, {np.nanmax(m.data):8.2f})")
|
|
43
|
+
|
|
44
|
+
print("\n== preprocessing ==")
|
|
45
|
+
g2 = g.detrend(degree=1).clip(1000, 8000, -8000, -1000).resample(2)
|
|
46
|
+
print(f" clipped/resampled shape: {g2.data.shape}, extent={g2.extent}")
|
|
47
|
+
|
|
48
|
+
print("\n== plotting (headless) ==")
|
|
49
|
+
import matplotlib
|
|
50
|
+
matplotlib.use("Agg")
|
|
51
|
+
import matplotlib.pyplot as plt
|
|
52
|
+
fig, axes = plt.subplots(2, 3, figsize=(15, 9))
|
|
53
|
+
for ax, m in zip(axes.ravel(), [g, g.hgm(), g.tilt(), g.tg(), g.up(500), g.hp_filter_uc(2000)]):
|
|
54
|
+
m.show(ax=ax, hs=True, colorbar=False)
|
|
55
|
+
out = Path(__file__).parent / "smoke_test_output.png"
|
|
56
|
+
fig.savefig(out, dpi=100, bbox_inches="tight")
|
|
57
|
+
print(f" saved -> {out}")
|
|
58
|
+
plt.close(fig)
|
|
59
|
+
|
|
60
|
+
print("\nALL OK")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite.spatial
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Lightweight coordinate helpers. Uses pyproj when available; falls back to
|
|
6
|
+
a no-op with a warning.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def project_points(points, src_srs: str = "EPSG:4326", dst_srs: str = "EPSG:32633"):
|
|
14
|
+
"""Reproject an (N, 2) array of points from src_srs to dst_srs."""
|
|
15
|
+
try:
|
|
16
|
+
from pyproj import Transformer
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise RuntimeError("pyproj is required: pip install pyproj")
|
|
19
|
+
t = Transformer.from_crs(src_srs, dst_srs, always_xy=True)
|
|
20
|
+
pts = np.asarray(points, dtype=float)
|
|
21
|
+
x, y = t.transform(pts[:, 0], pts[:, 1])
|
|
22
|
+
return np.column_stack([x, y])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def grid_extent(west: float, north: float, cellsize: float, nrows: int, ncols: float):
|
|
26
|
+
"""Return matplotlib-style [left, right, bottom, top]."""
|
|
27
|
+
return [west, west + ncols * cellsize, north - nrows * cellsize, north]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interpies_lite.transforms
|
|
3
|
+
==========================
|
|
4
|
+
|
|
5
|
+
Pure-numpy/scipy implementations of potential-field transforms:
|
|
6
|
+
derivatives (Savitzky-Golay / finite-sample / Fourier), vertical derivative
|
|
7
|
+
via the ISVD (integrated second vertical derivative) method, upward
|
|
8
|
+
continuation, vertical integration, detrending and padding helpers.
|
|
9
|
+
|
|
10
|
+
No rasterio / GDAL / obspy dependency here.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import ArrayLike
|
|
18
|
+
from scipy import ndimage as ndi
|
|
19
|
+
from scipy import signal
|
|
20
|
+
from sklearn.linear_model import LinearRegression
|
|
21
|
+
from sklearn.pipeline import Pipeline
|
|
22
|
+
from sklearn.preprocessing import PolynomialFeatures
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def replace_edges(data: np.ndarray, ncells: int = 1) -> np.ndarray:
|
|
26
|
+
out = data.copy()
|
|
27
|
+
for i in range(ncells):
|
|
28
|
+
out[i, :] = out[ncells, :]
|
|
29
|
+
out[-1 - i, :] = out[-1 - ncells, :]
|
|
30
|
+
out[:, i] = out[:, ncells]
|
|
31
|
+
out[:, -1 - i] = out[:, -1 - ncells]
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fill_nodata(data: np.ndarray, invalid: np.ndarray | None = None) -> np.ndarray:
|
|
36
|
+
if invalid is None:
|
|
37
|
+
invalid = ~np.isfinite(data)
|
|
38
|
+
if not invalid.any():
|
|
39
|
+
return data.copy()
|
|
40
|
+
return ndi.generic_filter(
|
|
41
|
+
np.where(invalid, np.nan, data),
|
|
42
|
+
lambda v: np.nanmean(v),
|
|
43
|
+
size=3,
|
|
44
|
+
mode="nearest",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def simple_resample(data: np.ndarray, sampling: int = 2) -> np.ndarray:
|
|
49
|
+
return data[::sampling, ::sampling]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def stats(data: np.ndarray) -> dict:
|
|
53
|
+
arr = data[np.isfinite(data)]
|
|
54
|
+
return {
|
|
55
|
+
"min": float(np.min(arr)),
|
|
56
|
+
"max": float(np.max(arr)),
|
|
57
|
+
"mean": float(np.mean(arr)),
|
|
58
|
+
"std": float(np.std(arr)),
|
|
59
|
+
"median": float(np.median(arr)),
|
|
60
|
+
"p02": float(np.percentile(arr, 2)),
|
|
61
|
+
"p98": float(np.percentile(arr, 98)),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_trend(point_coords: ArrayLike, data: ArrayLike, degree: int = 1, return_model: bool = False):
|
|
66
|
+
X = np.asarray(point_coords)
|
|
67
|
+
y = np.asarray(data).ravel()
|
|
68
|
+
mask = np.isfinite(y)
|
|
69
|
+
model = Pipeline([("poly", PolynomialFeatures(degree=degree)), ("reg", LinearRegression())])
|
|
70
|
+
model.fit(X[mask], y[mask])
|
|
71
|
+
if return_model:
|
|
72
|
+
return model
|
|
73
|
+
return model.predict(X).reshape(np.asarray(data).shape)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def savgol2d(degree: int, window_size: int) -> np.ndarray:
|
|
77
|
+
if window_size % 2 == 0:
|
|
78
|
+
raise ValueError("window_size must be odd")
|
|
79
|
+
half = window_size // 2
|
|
80
|
+
x = np.arange(-half, half + 1)
|
|
81
|
+
order = np.arange(degree + 1)
|
|
82
|
+
V = x[:, None] ** order[None, :]
|
|
83
|
+
coef_0 = np.linalg.pinv(V)[0]
|
|
84
|
+
kernel = np.outer(coef_0, coef_0)
|
|
85
|
+
return kernel / kernel.sum()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def savgol_smooth(data: np.ndarray, deg: int = 3, win: int = 5, do_edges: bool = False) -> np.ndarray:
|
|
89
|
+
kernel = savgol2d(deg, win)
|
|
90
|
+
out = signal.convolve2d(np.nan_to_num(data), kernel, mode="same", boundary="symm")
|
|
91
|
+
if do_edges:
|
|
92
|
+
out = replace_edges(out, ncells=win // 2)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def savgol_deriv(
|
|
97
|
+
data: np.ndarray,
|
|
98
|
+
cellsize: float,
|
|
99
|
+
direction: Literal["dx", "dy"] = "dx",
|
|
100
|
+
deg: int = 3,
|
|
101
|
+
win: int = 5,
|
|
102
|
+
do_edges: bool = True,
|
|
103
|
+
) -> np.ndarray:
|
|
104
|
+
if win % 2 == 0:
|
|
105
|
+
raise ValueError("win must be odd")
|
|
106
|
+
c_dx = signal.savgol_coeffs(win, deg, deriv=1, use="dot")
|
|
107
|
+
c_smooth = signal.savgol_coeffs(win, deg, deriv=0, use="dot")
|
|
108
|
+
if direction == "dx":
|
|
109
|
+
kx, ky = c_dx.reshape(1, -1), c_smooth.reshape(-1, 1)
|
|
110
|
+
else:
|
|
111
|
+
kx, ky = c_smooth.reshape(1, -1), c_dx.reshape(-1, 1)
|
|
112
|
+
kernel = ky @ kx
|
|
113
|
+
out = signal.convolve2d(np.nan_to_num(data), kernel, mode="same", boundary="symm") / cellsize
|
|
114
|
+
if do_edges:
|
|
115
|
+
out = replace_edges(out, ncells=win // 2)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fs_coefficients(tap: int = 5) -> np.ndarray:
|
|
120
|
+
x = np.arange(-tap, tap + 1, dtype=float)
|
|
121
|
+
A = np.vstack([np.ones_like(x), x]).T
|
|
122
|
+
return np.linalg.pinv(A)[1]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fs_deriv(data: np.ndarray, cellsize: float, direction: Literal["dx", "dy"] = "dx", tap: int = 5) -> np.ndarray:
|
|
126
|
+
c = fs_coefficients(tap)
|
|
127
|
+
kernel = c.reshape(1, -1) if direction == "dx" else c.reshape(-1, 1)
|
|
128
|
+
return signal.convolve2d(np.nan_to_num(data), kernel, mode="same", boundary="symm") / cellsize
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def next_pow2(x: int) -> int:
|
|
132
|
+
p = 1
|
|
133
|
+
while p < x:
|
|
134
|
+
p <<= 1
|
|
135
|
+
return p
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def getk(nx: int, ny: int, dx: float, dy: float):
|
|
139
|
+
fx = np.fft.fftfreq(nx, d=dx)
|
|
140
|
+
fy = np.fft.fftfreq(ny, d=dy)
|
|
141
|
+
kx, ky = np.meshgrid(2 * np.pi * fx, 2 * np.pi * fy)
|
|
142
|
+
return kx, ky, np.sqrt(kx**2 + ky**2)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def pad_full(data: np.ndarray, mode: str = "reflect") -> np.ndarray:
|
|
146
|
+
ny, nx = data.shape
|
|
147
|
+
return np.pad(data, ((ny // 2, ny // 2), (nx // 2, nx // 2)), mode=mode)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def unpad_full(data: np.ndarray, nrows: int, ncols: int) -> np.ndarray:
|
|
151
|
+
return data[nrows // 2:nrows // 2 + nrows, ncols // 2:ncols // 2 + ncols]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fourier_transform(
|
|
155
|
+
data: np.ndarray,
|
|
156
|
+
cellsize: float,
|
|
157
|
+
trans: Literal["dx", "dy", "dxdy", "dz", "vi", "upcont"] = "dx",
|
|
158
|
+
order: int = 1,
|
|
159
|
+
z: float = 0.0,
|
|
160
|
+
eps: float = 1e-6,
|
|
161
|
+
pad: bool = True,
|
|
162
|
+
) -> np.ndarray:
|
|
163
|
+
work = pad_full(data) if pad else data.copy()
|
|
164
|
+
ny, nx = work.shape
|
|
165
|
+
kx, ky, k = getk(nx, ny, cellsize, cellsize)
|
|
166
|
+
F = np.fft.fft2(np.nan_to_num(work))
|
|
167
|
+
|
|
168
|
+
if trans == "dx":
|
|
169
|
+
factor = (1j * kx) ** order
|
|
170
|
+
elif trans == "dy":
|
|
171
|
+
factor = (1j * ky) ** order
|
|
172
|
+
elif trans == "dxdy":
|
|
173
|
+
factor = (1j * kx) ** order * (1j * ky) ** order
|
|
174
|
+
elif trans == "dz":
|
|
175
|
+
factor = k**order
|
|
176
|
+
elif trans == "vi":
|
|
177
|
+
k_safe = k.copy()
|
|
178
|
+
k_safe[k_safe == 0] = eps
|
|
179
|
+
factor = k_safe ** (-order)
|
|
180
|
+
elif trans == "upcont":
|
|
181
|
+
factor = np.exp(-z * k)
|
|
182
|
+
else:
|
|
183
|
+
raise ValueError(f"Unknown transform: {trans}")
|
|
184
|
+
|
|
185
|
+
out = np.real(np.fft.ifft2(F * factor))
|
|
186
|
+
if pad:
|
|
187
|
+
out = unpad_full(out, data.shape[0], data.shape[1])
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def isvd(
|
|
192
|
+
data: np.ndarray,
|
|
193
|
+
cellsize: float,
|
|
194
|
+
method: Literal["SG", "FS", "fourier"] = "SG",
|
|
195
|
+
order: int = 1,
|
|
196
|
+
deg: int = 4,
|
|
197
|
+
win: int = 5,
|
|
198
|
+
fs_tap: int = 5,
|
|
199
|
+
do_edges: bool = True,
|
|
200
|
+
**kwargs,
|
|
201
|
+
) -> np.ndarray:
|
|
202
|
+
if method.upper() == "SG":
|
|
203
|
+
dx2 = savgol_deriv(data, cellsize, "dx", deg=deg, win=win, do_edges=do_edges)
|
|
204
|
+
dx2 = savgol_deriv(dx2, cellsize, "dx", deg=deg, win=win, do_edges=do_edges)
|
|
205
|
+
dy2 = savgol_deriv(data, cellsize, "dy", deg=deg, win=win, do_edges=do_edges)
|
|
206
|
+
dy2 = savgol_deriv(dy2, cellsize, "dy", deg=deg, win=win, do_edges=do_edges)
|
|
207
|
+
elif method.upper() == "FS":
|
|
208
|
+
dx2 = fs_deriv(fs_deriv(data, cellsize, "dx", tap=fs_tap), cellsize, "dx", tap=fs_tap)
|
|
209
|
+
dy2 = fs_deriv(fs_deriv(data, cellsize, "dy", tap=fs_tap), cellsize, "dy", tap=fs_tap)
|
|
210
|
+
else:
|
|
211
|
+
dx2 = fourier_transform(data, cellsize, trans="dx", order=2, **kwargs)
|
|
212
|
+
dy2 = fourier_transform(data, cellsize, trans="dy", order=2, **kwargs)
|
|
213
|
+
|
|
214
|
+
dz2 = -(dx2 + dy2)
|
|
215
|
+
if order == 2:
|
|
216
|
+
return dz2
|
|
217
|
+
if order == 1:
|
|
218
|
+
return fourier_transform(dz2, cellsize, trans="vi", order=1, pad=True)
|
|
219
|
+
raise NotImplementedError(f"ISVD order={order} not implemented")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def gauss(data: np.ndarray, sigma: float = 1.0) -> np.ndarray:
|
|
223
|
+
return ndi.gaussian_filter(np.nan_to_num(data), sigma=sigma)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def laplacian(data: np.ndarray, cellsize: float) -> np.ndarray:
|
|
227
|
+
return ndi.laplace(np.nan_to_num(data)) / (cellsize**2)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: interpies-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight reimplementation of interpies: potential-field grid transforms and plotting (derivatives, ISVD, upward continuation, edge enhancement).
|
|
5
|
+
Project-URL: Homepage, https://github.com/xyt556/interpies-lite
|
|
6
|
+
Project-URL: Repository, https://github.com/xyt556/interpies-lite
|
|
7
|
+
Project-URL: Issues, https://github.com/xyt556/interpies-lite/issues
|
|
8
|
+
Author-email: Yantao XI <xyt556@gmail.com>
|
|
9
|
+
License: BSD 3-Clause License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026, interpies-lite contributors
|
|
12
|
+
All rights reserved.
|
|
13
|
+
|
|
14
|
+
Redistribution and use in source and binary forms, with or without
|
|
15
|
+
modification, are permitted provided that the following conditions are met:
|
|
16
|
+
|
|
17
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
18
|
+
list of conditions and the following disclaimer.
|
|
19
|
+
|
|
20
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
21
|
+
this list of conditions and the following disclaimer in the documentation
|
|
22
|
+
and/or other materials provided with the distribution.
|
|
23
|
+
|
|
24
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
25
|
+
contributors may be used to endorse or promote products derived from
|
|
26
|
+
this software without specific prior written permission.
|
|
27
|
+
|
|
28
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
29
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
30
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
31
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
32
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
33
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
34
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
35
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
36
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
37
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
38
|
+
License-File: LICENSE
|
|
39
|
+
Keywords: derivatives,edge-detection,geophysics,gravity,isvd,magnetics,potential-field,raster,remote-sensing,tilt,upward-continuation
|
|
40
|
+
Classifier: Development Status :: 4 - Beta
|
|
41
|
+
Classifier: Intended Audience :: Science/Research
|
|
42
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
43
|
+
Classifier: Operating System :: OS Independent
|
|
44
|
+
Classifier: Programming Language :: Python :: 3
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
48
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
49
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
50
|
+
Requires-Python: >=3.10
|
|
51
|
+
Requires-Dist: matplotlib>=3.7
|
|
52
|
+
Requires-Dist: numpy>=1.24
|
|
53
|
+
Requires-Dist: scikit-learn>=1.2
|
|
54
|
+
Requires-Dist: scipy>=1.10
|
|
55
|
+
Provides-Extra: dev
|
|
56
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
57
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
58
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
59
|
+
Provides-Extra: geo
|
|
60
|
+
Requires-Dist: pyproj>=3.5; extra == 'geo'
|
|
61
|
+
Requires-Dist: rasterio>=1.3; extra == 'geo'
|
|
62
|
+
Description-Content-Type: text/markdown
|
|
63
|
+
|
|
64
|
+
# interpies_lite
|
|
65
|
+
|
|
66
|
+
对原 `interpies` 库的轻量重实现。目标:解决原库依赖钉死(`gdal==3.8.4`)、久未更新的问题,保留核心功能与 API 习惯。
|
|
67
|
+
|
|
68
|
+
## 与原库的差异
|
|
69
|
+
|
|
70
|
+
| 维度 | 原 interpies | interpies_lite |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| 栅格读写 | rasterio + 钉死 gdal==3.8.4 | rasterio>=1.3(可选,未装时只能用 `Grid.from_array`) |
|
|
73
|
+
| SEGY 模块 | obspy 依赖 | 砍掉 |
|
|
74
|
+
| 色表数据 | 35 KB 硬编码 LUT | 内置 Geosoft clra / parula 近似色表,其他直接用 matplotlib 色表 |
|
|
75
|
+
| Grid 实现 | 普通类 + 手写属性 | `@dataclass` + property,类型注解完整 |
|
|
76
|
+
| 依赖 | numpy/scipy/matplotlib/rasterio/gdal/scikit-image/scikit-learn | numpy/scipy/matplotlib/scikit-learn(rasterio/pyproj 可选) |
|
|
77
|
+
| Python | >=3.10 | >=3.10 |
|
|
78
|
+
|
|
79
|
+
## 模块
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
interpies_lite/
|
|
83
|
+
├── __init__.py # open() / Grid
|
|
84
|
+
├── grid.py # Grid 类:IO、几何、预处理、全部位场方法
|
|
85
|
+
├── transforms.py # 纯算法:导数、ISVD、傅里叶变换、延拓、平滑
|
|
86
|
+
├── graphics.py # show / save_image:晕渲、色图、等值线
|
|
87
|
+
├── colors.py # 内置色表
|
|
88
|
+
├── spatial.py # pyproj 点投影(可选)
|
|
89
|
+
└── io.py # rasterio 封装
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 快速开始
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import interpies_lite as ip
|
|
96
|
+
|
|
97
|
+
# 从文件加载(需要 rasterio)
|
|
98
|
+
g = ip.open("data/brtpgrd.gxf")
|
|
99
|
+
g.info()
|
|
100
|
+
|
|
101
|
+
# 从 numpy 数组构造(无任何重依赖)
|
|
102
|
+
import numpy as np
|
|
103
|
+
data = np.random.randn(100, 100)
|
|
104
|
+
g = ip.Grid.from_array(data, west=0, north=0, cellsize=100, crs="EPSG:32633")
|
|
105
|
+
|
|
106
|
+
# 链式调用:去趋势 → 平滑 → 边界识别
|
|
107
|
+
out = g.detrend(degree=1).smooth(win=5).tilt()
|
|
108
|
+
out.show()
|
|
109
|
+
|
|
110
|
+
# 可用变换(全部返回新 Grid):
|
|
111
|
+
# dx / dy / dx2 / dy2 / dxdy / dz / isvd / vi
|
|
112
|
+
# hgm / tilt / tg / lw / tahg
|
|
113
|
+
# up(z) / hp_filter_uc(z) / laplacian
|
|
114
|
+
# clip / resample / scale / detrend / fill_nodata / smooth
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 安装
|
|
118
|
+
|
|
119
|
+
本包暂未发布到 PyPI,可通过以下任一方式安装:
|
|
120
|
+
|
|
121
|
+
### 方式 1:从 GitHub 直接安装(推荐)
|
|
122
|
+
|
|
123
|
+
公开仓库,无需下载,pip 自动拉取最新代码:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install git+https://github.com/xyt556/interpies-lite.git
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
指定版本/分支:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install git+https://github.com/xyt556/interpies-lite.git@main
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 方式 2:从本地目录安装
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
cd path/to/interpies_lite
|
|
139
|
+
pip install .
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 方式 3:从 wheel 文件安装
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pip install dist/interpies_lite-0.1.0-py3-none-any.whl
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 方式 4:开发模式(可编辑,改代码即时生效)
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
cd path/to/interpies_lite
|
|
152
|
+
pip install -e .
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### 可选依赖(读写 GeoTIFF/GXF)
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
pip install "interpies-lite[geo]"
|
|
159
|
+
# 等价于
|
|
160
|
+
pip install rasterio pyproj
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
核心依赖(必装):numpy、scipy、matplotlib、scikit-learn。
|
|
164
|
+
|
|
165
|
+
## 示例
|
|
166
|
+
|
|
167
|
+
仓库自带两个 notebook:
|
|
168
|
+
|
|
169
|
+
| Notebook | 数据 | 内容 |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| `notebooks/interpies_lite_demo.ipynb` | 合成数据 | 快速上手,无需 rasterio,开箱即跑 |
|
|
172
|
+
| `notebooks/interpies_lite_brtpgrd_demo.ipynb` | `data/brtpgrd.gxf`(真实航磁) | 课堂版完整教程,含算法原理与公式 |
|
|
173
|
+
|
|
174
|
+
克隆仓库后:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cd interpies_lite
|
|
178
|
+
pip install -e .[geo]
|
|
179
|
+
jupyter notebook notebooks/
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`data/brtpgrd.gxf` 为 Colorado 地区航磁数据(707×629,50 m,NAD27/UTM13N),来自原 interpies 项目示例。
|
|
183
|
+
|
|
184
|
+
## 冒烟测试
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
python smoke_test.py
|
|
188
|
+
# 在合成网格上跑通全部变换,输出 smoke_test_output.png
|
|
189
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interpies_lite/__init__.py,sha256=2Dfz8DgJjVgB2w6yLMQh5MWSDEtZFjfUla52z3klrjo,671
|
|
2
|
+
interpies_lite/colors.py,sha256=Q8AY5CWIHJ8NXNVbB7lT3a93guaqqpVOHyzhxFrrTzE,1665
|
|
3
|
+
interpies_lite/graphics.py,sha256=UTXzzSdpRtuPyfSW-l_Ot_rFlTCwUVOlEiM54TrTflo,4844
|
|
4
|
+
interpies_lite/grid.py,sha256=aqRN0RqKwyZKdjxlBBMbaN4w7TD3b6pAiw19l2KvuaA,13727
|
|
5
|
+
interpies_lite/io.py,sha256=EAmkaXC9MX9gI17PCFCRMNKP4tluSUu9xcCDzRkI3ZE,551
|
|
6
|
+
interpies_lite/smoke_test.py,sha256=RFXtdAwOUOkizQIKoLkKQ0XtYhhJvmWwxEpKWRbHgVk,2348
|
|
7
|
+
interpies_lite/spatial.py,sha256=6uF3Kz59MgJfePfYjWaIwMrycHMaH6iI2BbSUwmPkRo,920
|
|
8
|
+
interpies_lite/transforms.py,sha256=oxGqJtgj-GwXZlchPNHOH4xxqfymH-LxnpArd1omM5Q,7325
|
|
9
|
+
interpies_lite-0.1.0.dist-info/METADATA,sha256=7Bl3MUNqbGLD7bOYTdp9nwwLduXgQ0EpkRQ2GQBSszg,6830
|
|
10
|
+
interpies_lite-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
interpies_lite-0.1.0.dist-info/licenses/LICENSE,sha256=KvHyl_BBFqqqQRqfXJ3SuUL09PYyJzTmS2Nqt8CMCPk,1535
|
|
12
|
+
interpies_lite-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, interpies-lite contributors
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|