qgis-tem-loader

qgis plugin for loading TEM geophysical inversion XYZ files as 3D objects
git clone git://src.adamsgaard.dk/qgis-tem-loader # fast
git clone https://src.adamsgaard.dk/qgis-tem-loader.git # slow
Log | Files | Refs | README | LICENSE Back to index

commit bc1454ab9f9ecc1060fe1377e0d921d92a40cadf
parent bf942038f18bed7953af99b6c2054dbebdb5c7d2
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Sat, 27 Jun 2026 07:55:40 +0200

fix(qgis): handle empty interpolation extent without crashing

Diffstat:
Mtem_loader/tem_loader.py | 453++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 452 insertions(+), 1 deletion(-)

diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py @@ -7,9 +7,15 @@ from qgis.PyQt.QtWidgets import ( QCheckBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFileDialog, QFormLayout, + QLabel, + QListWidget, + QListWidgetItem, QMessageBox, + QProgressDialog, + QPushButton, QSpinBox, QVBoxLayout, ) @@ -30,18 +36,31 @@ from qgis.core import ( from qgis.gui import QgsMapLayerComboBox from . import core +from . import interpolate as interp STYLES_DIR = Path(__file__).parent / 'styles' GEOMETRY_FIELD = 'Geometry' STRING_FIELDS = {'Line', 'StationNo', 'Color'} -INTEGER_FIELDS = {'NumLayers', 'Layer', 'Opacity'} +INTEGER_FIELDS = {'NumLayers', 'Layer', 'Opacity', 'NumSamples', 'CellX', 'CellY', 'CellZ'} def _build_geopackage_uri(gpkg_path, layer_name): return f'{gpkg_path.resolve()}|layername={layer_name}' +def _voxel_output_path(input_layers): + """Auto-pick the voxel GeoPackage path next to the import's GeoPackage, + named after it with a ``_voxels`` suffix. Returns ``None`` if no input layer + is backed by a GeoPackage on disk.""" + for lyr in input_layers: + source = lyr.source().split('|', 1)[0] + src = Path(source) + if src.suffix.lower() == '.gpkg' and src.parent.is_dir(): + return src.with_name(f'{src.stem}_voxels.gpkg') + return None + + def _field_type(field_name): if field_name in STRING_FIELDS: return QMetaType.Type.QString @@ -296,19 +315,302 @@ def _exec_dialog(dialog): return execute() +class _InterpolationDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('3D IDW Interpolation') + + project = QgsProject.instance() + all_layers = list(project.mapLayers().values()) if hasattr(project, 'mapLayers') else [] + + self._input_list = QListWidget() + self._input_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection + if hasattr(QListWidget, 'SelectionMode') + else QListWidget.MultiSelection) + linestring_z_wkb = Qgis.WkbType.LineStringZ + for lyr in all_layers: + if hasattr(lyr, 'wkbType') and lyr.wkbType() == linestring_z_wkb: + item = QListWidgetItem(lyr.name()) + item.setData(32, lyr) + self._input_list.addItem(item) + + self._doi_list = QListWidget() + self._doi_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection + if hasattr(QListWidget, 'SelectionMode') + else QListWidget.MultiSelection) + point_z_wkb = Qgis.WkbType.PointZ + for lyr in all_layers: + if hasattr(lyr, 'wkbType') and lyr.wkbType() == point_z_wkb: + item = QListWidgetItem(lyr.name()) + item.setData(32, lyr) + self._doi_list.addItem(item) + if 'doi' in lyr.name().lower(): + item.setSelected(True) + + self._xmin = QDoubleSpinBox() + self._xmax = QDoubleSpinBox() + self._ymin = QDoubleSpinBox() + self._ymax = QDoubleSpinBox() + self._zmin = QDoubleSpinBox() + self._zmax = QDoubleSpinBox() + for sb in (self._xmin, self._xmax, self._ymin, self._ymax, + self._zmin, self._zmax): + sb.setRange(-1e9, 1e9) + sb.setDecimals(3) + + self._dx = QDoubleSpinBox() + self._dy = QDoubleSpinBox() + self._dz = QDoubleSpinBox() + self._nx = QSpinBox() + self._ny = QSpinBox() + self._nz = QSpinBox() + for sb in (self._dx, self._dy, self._dz): + sb.setRange(0.001, 1e6) + sb.setDecimals(3) + sb.setValue(100.0) + for sb in (self._nx, self._ny, self._nz): + sb.setRange(1, 10000) + sb.setValue(10) + + self._summary_label = QLabel() + + self._power = QDoubleSpinBox() + self._power.setRange(0.1, 10.0) + self._power.setValue(2.0) + self._power.setDecimals(1) + + self._log10_checkbox = QCheckBox('Interpolate in log10 space') + self._log10_checkbox.setChecked(True) + + self._clip_doi_checkbox = QCheckBox('Clip voxels below per-sounding DOI') + self._clip_doi_checkbox.setChecked(True) + + self._anisotropy = QDoubleSpinBox() + self._anisotropy.setRange(0.01, 100.0) + self._anisotropy.setValue(1.0) + self._anisotropy.setDecimals(2) + + self._radius = QDoubleSpinBox() + self._radius.setRange(0.0, 1e9) + self._radius.setDecimals(2) + self._radius.setValue(0.0) + self._radius.setSpecialValueText('Auto') + + self._min_neighbors = QSpinBox() + self._min_neighbors.setRange(1, 1000) + self._min_neighbors.setValue(1) + + self._max_neighbors = QSpinBox() + self._max_neighbors.setRange(0, 10000) + self._max_neighbors.setValue(0) + self._max_neighbors.setSpecialValueText('Unlimited') + + use_extent_btn = QPushButton('Use extent of selected input layers') + use_extent_btn.clicked.connect(self._fill_extent_from_inputs) + + self._dx.valueChanged.connect(self._on_cell_size_changed) + self._dy.valueChanged.connect(self._on_cell_size_changed) + self._dz.valueChanged.connect(self._on_cell_size_changed) + self._nx.valueChanged.connect(self._on_cell_count_changed) + self._ny.valueChanged.connect(self._on_cell_count_changed) + self._nz.valueChanged.connect(self._on_cell_count_changed) + self._xmin.valueChanged.connect(self._update_summary) + self._xmax.valueChanged.connect(self._update_summary) + self._ymin.valueChanged.connect(self._update_summary) + self._ymax.valueChanged.connect(self._update_summary) + self._zmin.valueChanged.connect(self._update_summary) + self._zmax.valueChanged.connect(self._update_summary) + + self._updating = False + + buttons = QDialogButtonBox(_dialog_buttons()) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + extent_form = QFormLayout() + extent_form.addRow('X min', self._xmin) + extent_form.addRow('X max', self._xmax) + extent_form.addRow('Y min', self._ymin) + extent_form.addRow('Y max', self._ymax) + extent_form.addRow('Z min', self._zmin) + extent_form.addRow('Z max', self._zmax) + + size_form = QFormLayout() + size_form.addRow('Cell size X (m)', self._dx) + size_form.addRow('Cell size Y (m)', self._dy) + size_form.addRow('Cell size Z (m)', self._dz) + size_form.addRow('Cell count X', self._nx) + size_form.addRow('Cell count Y', self._ny) + size_form.addRow('Cell count Z', self._nz) + size_form.addRow('', self._summary_label) + + param_form = QFormLayout() + param_form.addRow('IDW power', self._power) + param_form.addRow('Vertical anisotropy', self._anisotropy) + param_form.addRow('Search radius (m)', self._radius) + param_form.addRow('Min neighbors', self._min_neighbors) + param_form.addRow('Max neighbors', self._max_neighbors) + + layout = QVBoxLayout() + layout.addWidget(QLabel('Input layers (LineStringZ):')) + layout.addWidget(self._input_list) + layout.addWidget(QLabel('DOI layers (PointZ, optional):')) + layout.addWidget(self._doi_list) + layout.addWidget(use_extent_btn) + layout.addLayout(extent_form) + layout.addLayout(size_form) + layout.addWidget(self._log10_checkbox) + layout.addWidget(self._clip_doi_checkbox) + layout.addLayout(param_form) + layout.addWidget(buttons) + self.setLayout(layout) + + self._update_summary() + + def _fill_extent_from_inputs(self): + layers = self._selected_input_layers() + if not layers: + return + xs, ys, zs = [], [], [] + for lyr in layers: + ext = lyr.extent() + xs += [ext.xMinimum(), ext.xMaximum()] + ys += [ext.yMinimum(), ext.yMaximum()] + for feat in lyr.getFeatures(): + geom = feat.geometry() + if geom is None: + continue + for vertex in geom.vertices(): + z = vertex.z() + if z == z: # exclude NaN + zs.append(z) + self._updating = True + self._xmin.setValue(min(xs)) + self._xmax.setValue(max(xs)) + self._ymin.setValue(min(ys)) + self._ymax.setValue(max(ys)) + if zs: + self._zmin.setValue(min(zs)) + self._zmax.setValue(max(zs)) + self._updating = False + self._update_summary() + + def _selected_input_layers(self): + layers = [] + for i in range(self._input_list.count()): + item = self._input_list.item(i) + if item.isSelected(): + lyr = item.data(32) + if lyr is not None: + layers.append(lyr) + return layers + + def _selected_doi_layers(self): + layers = [] + for i in range(self._doi_list.count()): + item = self._doi_list.item(i) + if item.isSelected(): + lyr = item.data(32) + if lyr is not None: + layers.append(lyr) + return layers + + def _on_cell_size_changed(self): + if self._updating: + return + self._updating = True + try: + xext = self._xmax.value() - self._xmin.value() + yext = self._ymax.value() - self._ymin.value() + zext = self._zmax.value() - self._zmin.value() + if xext > 0: + self._nx.setValue(interp.axis_count(xext, self._dx.value())) + if yext > 0: + self._ny.setValue(interp.axis_count(yext, self._dy.value())) + if zext > 0: + self._nz.setValue(interp.axis_count(zext, self._dz.value())) + except (ValueError, ZeroDivisionError): + pass + finally: + self._updating = False + self._update_summary() + + def _on_cell_count_changed(self): + if self._updating: + return + self._updating = True + try: + xext = self._xmax.value() - self._xmin.value() + yext = self._ymax.value() - self._ymin.value() + zext = self._zmax.value() - self._zmin.value() + if xext > 0: + self._dx.setValue(interp.axis_cell_size(xext, self._nx.value())) + if yext > 0: + self._dy.setValue(interp.axis_cell_size(yext, self._ny.value())) + if zext > 0: + self._dz.setValue(interp.axis_cell_size(zext, self._nz.value())) + except (ValueError, ZeroDivisionError): + pass + finally: + self._updating = False + self._update_summary() + + def _update_summary(self): + nx, ny, nz = self._nx.value(), self._ny.value(), self._nz.value() + total = nx * ny * nz + self._summary_label.setText( + f'{nx} × {ny} × {nz} = {total:,} cells' + ) + + def options(self): + bounds = ( + self._xmin.value(), self._xmax.value(), + self._ymin.value(), self._ymax.value(), + self._zmin.value(), self._zmax.value(), + ) + grid = interp.GridSpec.from_cell_count( + bounds, + self._nx.value(), + self._ny.value(), + self._nz.value(), + ) + radius = self._radius.value() + max_neighbors = self._max_neighbors.value() + return { + 'input_layers': self._selected_input_layers(), + 'doi_layers': self._selected_doi_layers(), + 'grid': grid, + 'power': self._power.value(), + 'log10': self._log10_checkbox.isChecked(), + 'clip_below_doi': self._clip_doi_checkbox.isChecked(), + 'anisotropy_z': self._anisotropy.value(), + 'radius': radius if radius > 0 else None, + 'min_neighbors': self._min_neighbors.value(), + 'max_neighbors': max_neighbors if max_neighbors > 0 else None, + } + + class TEMLoaderPlugin: def __init__(self, iface): self.iface = iface self._action = None + self._interp_action = None def initGui(self): self._action = QAction('Load TEM XYZ files\u2026', self.iface.mainWindow()) self._action.triggered.connect(self.run) self.iface.addPluginToMenu('TEM Loader', self._action) + self._interp_action = QAction('3D interpolation\u2026', self.iface.mainWindow()) + self._interp_action.triggered.connect(self.run_interpolation) + self.iface.addPluginToMenu('TEM Loader', self._interp_action) + def unload(self): self.iface.removePluginMenu('TEM Loader', self._action) self._action = None + if self._interp_action is not None: + self.iface.removePluginMenu('TEM Loader', self._interp_action) + self._interp_action = None def run(self): paths, _ = QFileDialog.getOpenFileNames( @@ -354,6 +656,155 @@ class TEMLoaderPlugin: crs.createFromString('EPSG:4326') return crs + def run_interpolation(self): + dialog = _InterpolationDialog(self.iface.mainWindow()) + if _exec_dialog(dialog) != _dialog_code('Accepted'): + return + + try: + opts = dialog.options() + except ValueError as exc: + QMessageBox.warning( + self.iface.mainWindow(), + 'TEM Loader', + f'Invalid grid: {exc}. Set a non-empty extent (e.g. use ' + '"Use extent of selected input layers").', + ) + return + input_layers = opts['input_layers'] + if not input_layers: + QMessageBox.warning( + self.iface.mainWindow(), + 'TEM Loader', + 'No input layers selected.', + ) + return + + gpkg_path = _voxel_output_path(input_layers) + if gpkg_path is None: + QMessageBox.warning( + self.iface.mainWindow(), + 'TEM Loader', + 'Could not determine an output location: the selected input ' + 'layers are not backed by a GeoPackage on disk.', + ) + return + + try: + self._run_interpolation(gpkg_path, opts, input_layers) + except ValueError as exc: + QMessageBox.warning( + self.iface.mainWindow(), + 'TEM Loader', + str(exc), + ) + + def _run_interpolation(self, gpkg_path, opts, input_layers): + required = ('X', 'Y', 'ZMid', 'Resistivity') + for lyr in input_layers: + names = {field.name() for field in lyr.fields()} + missing = [name for name in required if name not in names] + if missing: + raise ValueError( + f'Layer "{lyr.name()}" is missing required field(s): ' + f'{", ".join(missing)}. Select TEM "layers" outputs.' + ) + + layer_rows = [] + for lyr in input_layers: + for feat in lyr.getFeatures(): + attrs = {field.name(): feat[field.name()] for field in lyr.fields()} + geom = feat.geometry() + layer_rows.append({ + 'X': attrs.get('X'), + 'Y': attrs.get('Y'), + 'ZMid': attrs.get('ZMid'), + 'Resistivity': attrs.get('Resistivity'), + 'Geometry': geom.asWkt() if geom else '', + }) + + doi_rows = [] + for lyr in opts['doi_layers']: + for feat in lyr.getFeatures(): + attrs = {field.name(): feat[field.name()] for field in lyr.fields()} + doi_rows.append({ + 'X': attrs.get('X'), + 'Y': attrs.get('Y'), + 'ZDOI': attrs.get('ZDOI'), + }) + + progress_dialog = QProgressDialog( + 'Interpolating voxels…', 'Cancel', 0, 100, + self.iface.mainWindow(), + ) + progress_dialog.setWindowTitle('TEM Loader') + progress_dialog.setMinimumDuration(500) + + last_pct = [-1] + + def on_progress(done, total): + pct = int(100 * done / total) if total else 100 + # Only touch the widget on whole-percent changes; the callback fires + # once per cell (up to several million times). + if pct != last_pct[0]: + last_pct[0] = pct + progress_dialog.setValue(pct) + return progress_dialog.wasCanceled() + + grid = opts['grid'] + voxels = interp.interpolate_voxels( + layer_rows, + grid, + doi_points=doi_rows if doi_rows else None, + clip_below_doi=opts['clip_below_doi'], + power=opts['power'], + radius=opts['radius'], + min_neighbors=opts['min_neighbors'], + max_neighbors=opts['max_neighbors'], + anisotropy_z=opts['anisotropy_z'], + log10=opts['log10'], + progress=on_progress, + ) + progress_dialog.setValue(100) + + if progress_dialog.wasCanceled(): + raise ValueError('Interpolation cancelled.') + + if not voxels: + raise ValueError('Interpolation produced no voxels. Check extent and input layers.') + + project = QgsProject.instance() + crs = input_layers[0].crs() + transform_context = project.transformContext() + + _write_geopackage_layer( + voxels, + gpkg_path, + 'voxels', + Qgis.WkbType.PointZ, + crs, + transform_context, + QgsVectorFileWriter.CreateOrOverwriteFile, + ) + + uri = _build_geopackage_uri(gpkg_path, 'voxels') + layer = QgsVectorLayer(uri, 'voxels', 'ogr') + if not layer.isValid(): + raise ValueError(f'Failed to load voxel layer from {gpkg_path}') + + qml = STYLES_DIR / 'voxels.qml' + if qml.exists(): + layer.loadNamedStyle(str(qml)) + + project.addMapLayer(layer, False) + root = project.layerTreeRoot() + group = root.insertGroup(0, 'Interpolation') + group.insertLayer(0, layer) + + self.iface.messageBar().pushInfo( + 'TEM Loader', f'Voxel layer saved to {gpkg_path}' + ) + def _load_xyz( self, filepath,