Skip to content

Commit acf4037

Browse files
authored
feat: add Tanager STAC search dialog to QGIS plugin (#267)
* feat: add Tanager STAC search dialog to QGIS plugin Adds a dockable Tanager search panel that lets users query Planet Tanager STAC scenes, add footprint layers, open orthorectified visual imagery, and download radiance HDF5 files directly from QGIS. * feat: enhance Tanager search dialog and functionality with new asset handling and UI improvements * feat: implement value range handling for Tanager data type in Load Data Dialog * Address Copilot review feedback - Add .hdf5 to DATA_TYPES["Tanager"]["extensions"] so the file browser filter exposes .hdf5 Tanager files, matching the new content-based detection in _detect_type(). - Track the last default applied to the value-range spinboxes in LoadDataDialog and add a preserve_user_edits flag to _apply_data_type_value_range. Preview and load completion now skip the default re-apply when the user has edited Min/Max since the last default, preventing custom visualization ranges from being clobbered before the layer is rendered. * feat: enhance Tanager data handling with 2D geolocation extraction and default plot settings * Fix PyQt6 import smoke test failure when matplotlib is unavailable - Guard _apply_data_type_plot_defaults with a HAS_MATPLOTLIB check so add_spectrum() does not AttributeError on ylabel_combo when the dialog ran the matplotlib-less setup_ui() fallback (matches the pattern used by update_plot and similar methods). - Skip test_spectral_plot_uses_tanager_radiance_defaults via pytest.importorskip("matplotlib") so the assertions only run in environments where the dialog widgets actually exist. The PyQt6 import smoke CI job installs PyQt6 + numpy + Pillow only, not matplotlib, so the previous test crashed there. * feat: update Tanager search dialog to handle footprints without GeoDataFrame and improve selection functionality
1 parent fa67204 commit acf4037

12 files changed

Lines changed: 2516 additions & 15 deletions

qgis_plugin/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Check out this [short video demo](https://youtu.be/EEUAC5BxqtM) and [full video
1212

1313
- **Load Multiple Hyperspectral Formats**: Support for NASA EMIT, PACE, DESIS, NEON AOP, AVIRIS, PRISMA, EnMAP, Planet Tanager, Wyvern, and generic GeoTIFF/NetCDF hyperspectral data.
1414

15+
- **Tanager Search and Visualization**: Search Planet Tanager STAC scenes from QGIS, add footprint layers, open orthorectified visual imagery, and download orthorectified radiance HDF5 files for hyperspectral analysis.
16+
1517
- **Band Combination Visualization**: Easily change RGB band combinations using wavelength values. Includes presets for:
1618

1719
- True Color (RGB)
@@ -81,6 +83,14 @@ When QGIS is launched from this Conda environment, the plugin automatically dete
8183
4. Configure RGB wavelengths for visualization
8284
5. Click **Load Data**
8385

86+
### Searching Tanager Data
87+
88+
1. Click the **Search Tanager Data** button
89+
2. Use the current map extent or enter a bounding box
90+
3. Set optional date, collection, query, cloud, and count filters
91+
4. Click **Search**
92+
5. Add footprints, open the visual asset, or download the radiance HDF5 for analysis
93+
8494
### Changing Band Combinations
8595

8696
1. Click the **Band Combination** button

qgis_plugin/hypercoast_qgis/dialogs/load_data_dialog.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
)
4848
from ..cache_manager import create_generated_raster_path
4949

50+
DEFAULT_VALUE_RANGE = (0.0, 0.5)
51+
TANAGER_VALUE_RANGE = (0.0, 100.0)
52+
5053

5154
class DatasetPreviewWorker(QThread):
5255
"""Worker thread for loading dataset metadata without blocking QGIS."""
@@ -161,6 +164,10 @@ def __init__(self, iface, plugin, parent=None):
161164
self._preview_worker = None
162165
self._load_worker = None
163166
self._pending_load_context = None
167+
# Track the last default the dialog applied to the value-range spinboxes
168+
# so background callbacks can detect user edits and avoid overwriting
169+
# them. Initialized to match the spinbox values set in setup_ui().
170+
self._last_applied_value_range = (0.0, DEFAULT_VALUE_RANGE[1])
164171

165172
self.setWindowTitle("Load Hyperspectral Data")
166173
self.setObjectName("HyperCoastLoadDataDock")
@@ -253,7 +260,7 @@ def setup_ui(self):
253260

254261
self.vmax_spin = QDoubleSpinBox()
255262
self.vmax_spin.setRange(-1000, 1000)
256-
self.vmax_spin.setValue(0.5)
263+
self.vmax_spin.setValue(DEFAULT_VALUE_RANGE[1])
257264
self.vmax_spin.setDecimals(4)
258265
range_layout.addWidget(QLabel("Max:"))
259266
range_layout.addWidget(self.vmax_spin)
@@ -358,12 +365,61 @@ def browse_file(self):
358365
self.dataset = None
359366
self.info_text.clear()
360367
self._populate_variable_combo(None)
368+
self._apply_data_type_value_range(
369+
self._resolved_value_range_data_type(filepath)
370+
)
361371

362372
def _clear_dataset_preview(self, *args):
363373
"""Clear loaded preview state after the requested data type changes."""
364374
self.dataset = None
365375
self.info_text.clear()
366376
self._populate_variable_combo(None)
377+
self._apply_data_type_value_range(self._resolved_value_range_data_type())
378+
379+
def _resolved_value_range_data_type(self, filepath=None):
380+
"""Return the data type to use for visualization range defaults.
381+
382+
Args:
383+
filepath: Optional data file path to inspect for auto-detection.
384+
385+
Returns:
386+
Selected or auto-detected data type.
387+
"""
388+
data_type = self.data_type_combo.currentData()
389+
if data_type != "auto":
390+
return data_type
391+
filepath = filepath or self.file_path_edit.text()
392+
if filepath and os.path.exists(filepath):
393+
return HyperspectralDataset(filepath, "auto").data_type
394+
return data_type
395+
396+
def _apply_data_type_value_range(self, data_type, preserve_user_edits=False):
397+
"""Apply visualization range defaults for a data type.
398+
399+
Args:
400+
data_type: Resolved HyperCoast data type.
401+
preserve_user_edits: When True, skip updating the spinboxes if the
402+
user has changed them since the last default was applied. Use
403+
this from background callbacks (preview/load completion) so
404+
custom min/max values entered before the callback fired are
405+
not overwritten.
406+
"""
407+
vmin, vmax = (
408+
TANAGER_VALUE_RANGE if data_type == "Tanager" else DEFAULT_VALUE_RANGE
409+
)
410+
if preserve_user_edits:
411+
last_vmin, last_vmax = self._last_applied_value_range
412+
current_vmin = self.vmin_spin.value()
413+
current_vmax = self.vmax_spin.value()
414+
tolerance = 10 ** -self.vmin_spin.decimals()
415+
if (
416+
abs(current_vmin - last_vmin) > tolerance
417+
or abs(current_vmax - last_vmax) > tolerance
418+
):
419+
return
420+
self.vmin_spin.setValue(vmin)
421+
self.vmax_spin.setValue(vmax)
422+
self._last_applied_value_range = (vmin, vmax)
367423

368424
def preview_dataset(self):
369425
"""Preview the selected dataset."""
@@ -400,6 +456,9 @@ def _on_preview_finished(self, dataset, error_detail):
400456
try:
401457
if dataset is not None:
402458
self.dataset = dataset
459+
self._apply_data_type_value_range(
460+
dataset.data_type, preserve_user_edits=True
461+
)
403462
self.progress_bar.setValue(80)
404463
self.info_text.setText(self._format_dataset_info(dataset))
405464
self._populate_variable_combo(dataset)
@@ -620,6 +679,9 @@ def _on_load_finished(self, dataset, temp_path, error_detail):
620679
raise ValueError(f"Failed to load dataset. Details: {error_detail}")
621680

622681
self.dataset = dataset
682+
self._apply_data_type_value_range(
683+
self.dataset.data_type, preserve_user_edits=True
684+
)
623685
self.progress_bar.setValue(80)
624686
selected_data_var = self.dataset.get_data_variable()
625687
selected_variable = variable_name or getattr(

qgis_plugin/hypercoast_qgis/dialogs/spectral_inspector_tool.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,12 @@ def extract_spectrum_at_point(self, point):
165165
# Update spectral plot dialog if open
166166
if self.plugin.spectral_plot_dialog:
167167
self.plugin.spectral_plot_dialog.add_spectrum(
168-
lat, lon, wavelengths, values, layer.name()
168+
lat,
169+
lon,
170+
wavelengths,
171+
values,
172+
layer.name(),
173+
data_info.get("data_type"),
169174
)
170175

171176
QgsMessageLog.logMessage(

qgis_plugin/hypercoast_qgis/dialogs/spectral_plot_dialog.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
except ImportError:
5050
HAS_MATPLOTLIB = False
5151

52+
DEFAULT_Y_RANGE = (0.0, 0.5)
53+
TANAGER_Y_RANGE = (0.0, 100.0)
54+
5255

5356
class SpectralPlotDialog(QDockWidget):
5457
"""Dockable panel for displaying spectral plots."""
@@ -134,6 +137,7 @@ def setup_ui(self):
134137
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon
135138
)
136139
self.layer_combo.setSizePolicy(policy.Expanding, policy.Fixed)
140+
self.layer_combo.currentIndexChanged.connect(self._on_selected_layer_changed)
137141
sampling_layout.addWidget(QLabel("Layer:"))
138142
sampling_layout.addWidget(self.layer_combo, 1)
139143

@@ -188,14 +192,14 @@ def setup_ui(self):
188192

189193
ylim_layout = QHBoxLayout()
190194
self.ymin_spin = QDoubleSpinBox()
191-
self.ymin_spin.setRange(-1, 10)
192-
self.ymin_spin.setValue(0)
195+
self.ymin_spin.setRange(-1000, 10000)
196+
self.ymin_spin.setValue(DEFAULT_Y_RANGE[0])
193197
self.ymin_spin.setDecimals(4)
194198
ylim_layout.addWidget(self.ymin_spin)
195199
ylim_layout.addWidget(QLabel("-"))
196200
self.ymax_spin = QDoubleSpinBox()
197-
self.ymax_spin.setRange(-1, 10)
198-
self.ymax_spin.setValue(0.5)
201+
self.ymax_spin.setRange(-1000, 10000)
202+
self.ymax_spin.setValue(DEFAULT_Y_RANGE[1])
199203
self.ymax_spin.setDecimals(4)
200204
ylim_layout.addWidget(self.ymax_spin)
201205
axis_form.addRow("Y Range:", ylim_layout)
@@ -324,6 +328,7 @@ def refresh_layer_combo(self):
324328
self.layer_combo.setCurrentIndex(index)
325329

326330
self.layer_combo.blockSignals(False)
331+
self._apply_data_type_plot_defaults(self._selected_layer_data_type())
327332

328333
def selected_layer_id(self):
329334
"""Return the selected sampling layer ID.
@@ -335,6 +340,45 @@ def selected_layer_id(self):
335340
return None
336341
return self.layer_combo.currentData()
337342

343+
def _on_selected_layer_changed(self, *args):
344+
"""Apply plot defaults when the selected sampling layer changes."""
345+
self._apply_data_type_plot_defaults(self._selected_layer_data_type())
346+
347+
def _selected_layer_data_type(self):
348+
"""Return the data type represented by the selected sampling layer.
349+
350+
Returns:
351+
HyperCoast data type string, or ``None`` for mixed/unknown layers.
352+
"""
353+
layer_id = self.selected_layer_id()
354+
if layer_id:
355+
data_info = self.plugin.get_hyperspectral_data(layer_id) or {}
356+
return data_info.get("data_type")
357+
358+
data_types = {
359+
(self.plugin.get_hyperspectral_data(layer_id) or {}).get("data_type")
360+
for layer_id in self.plugin.get_all_hyperspectral_layers()
361+
}
362+
data_types.discard(None)
363+
if len(data_types) == 1:
364+
return next(iter(data_types))
365+
return None
366+
367+
def _apply_data_type_plot_defaults(self, data_type):
368+
"""Apply axis label and range defaults for a data type.
369+
370+
Args:
371+
data_type: HyperCoast data type string.
372+
"""
373+
if not HAS_MATPLOTLIB or data_type != "Tanager":
374+
return
375+
376+
index = self.ylabel_combo.findText("Radiance")
377+
if index >= 0:
378+
self.ylabel_combo.setCurrentIndex(index)
379+
self.ymin_spin.setValue(TANAGER_Y_RANGE[0])
380+
self.ymax_spin.setValue(TANAGER_Y_RANGE[1])
381+
338382
def _project_layer(self, layer_id):
339383
"""Return a QGIS project layer by ID.
340384
@@ -351,22 +395,25 @@ def _project_layer(self, layer_id):
351395
except Exception:
352396
return None
353397

354-
def add_spectrum(self, lat, lon, wavelengths, values, layer_name):
398+
def add_spectrum(self, lat, lon, wavelengths, values, layer_name, data_type=None):
355399
"""Add a new spectrum to the plot.
356400
357401
:param lat: Latitude
358402
:param lon: Longitude
359403
:param wavelengths: Array of wavelengths
360404
:param values: Array of spectral values
361405
:param layer_name: Name of the source layer
406+
:param data_type: Optional HyperCoast data type.
362407
"""
408+
self._apply_data_type_plot_defaults(data_type)
363409
spectrum = {
364410
"lat": lat,
365411
"lon": lon,
366412
"wavelengths": np.array(wavelengths),
367413
"values": np.array(values),
368414
"layer": layer_name,
369415
"label": f"({lat:.4f}, {lon:.4f})",
416+
"data_type": data_type,
370417
}
371418

372419
self.spectra.append(spectrum)

0 commit comments

Comments
 (0)