viseda 1.0.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.
- viseda/__init__.py +20 -0
- viseda/cli.py +144 -0
- viseda/core/__init__.py +3 -0
- viseda/core/base.py +69 -0
- viseda/hyperspectral/__init__.py +1 -0
- viseda/hyperspectral/eda.py +1849 -0
- viseda/image/__init__.py +1 -0
- viseda/image/eda.py +1840 -0
- viseda/pointcloud/__init__.py +3 -0
- viseda/pointcloud/eda.py +1167 -0
- viseda/report/__init__.py +3 -0
- viseda/report/html_report.py +213 -0
- viseda/text/__init__.py +3 -0
- viseda/text/eda.py +1613 -0
- viseda/utils/__init__.py +27 -0
- viseda/utils/helpers.py +120 -0
- viseda/video/__init__.py +3 -0
- viseda/video/eda.py +587 -0
- viseda-1.0.0.dist-info/METADATA +266 -0
- viseda-1.0.0.dist-info/RECORD +24 -0
- viseda-1.0.0.dist-info/WHEEL +5 -0
- viseda-1.0.0.dist-info/entry_points.txt +2 -0
- viseda-1.0.0.dist-info/licenses/LICENSE +21 -0
- viseda-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1849 @@
|
|
|
1
|
+
"""
|
|
2
|
+
viseda.hyperspectral.eda
|
|
3
|
+
========================
|
|
4
|
+
Comprehensive EDA for hyperspectral / multispectral image datasets.
|
|
5
|
+
|
|
6
|
+
Supports analysing a **single cube** or a **whole directory** of cubes
|
|
7
|
+
through one unified class — ``HyperspectralEDA``.
|
|
8
|
+
|
|
9
|
+
Supported file formats
|
|
10
|
+
----------------------
|
|
11
|
+
* ``.mat`` — MATLAB (scipy.io) e.g. Indian Pines, Pavia, Salinas
|
|
12
|
+
* ``.npy`` — NumPy array (H × W × B)
|
|
13
|
+
* ``.npz`` — NumPy archive (first key)
|
|
14
|
+
* ``.hdr`` — ENVI header + sidecar requires ``pip install spectral``
|
|
15
|
+
* ``.tif / .tiff`` — Multi-band GeoTIFF requires ``pip install rasterio``
|
|
16
|
+
|
|
17
|
+
Cube layout
|
|
18
|
+
-----------
|
|
19
|
+
All cubes are expected / normalised to shape ``(H, W, B)`` — height × width
|
|
20
|
+
× bands. Single-band images are treated as ``(H, W, 1)``.
|
|
21
|
+
|
|
22
|
+
Analyses — per cube
|
|
23
|
+
-------------------
|
|
24
|
+
INVENTORY
|
|
25
|
+
shape, dtype, file size, label (from folder name or user dict)
|
|
26
|
+
|
|
27
|
+
SPECTRAL STATISTICS (computed per band, stored as arrays of length B)
|
|
28
|
+
mean, std, min, max, SNR (mean/std), noise (MAD),
|
|
29
|
+
saturation fraction, dynamic range per band
|
|
30
|
+
|
|
31
|
+
SPATIAL STATISTICS
|
|
32
|
+
spatial mean map (H × W average across bands)
|
|
33
|
+
spatial std map
|
|
34
|
+
|
|
35
|
+
SPECTRAL INDICES (when wavelengths are provided)
|
|
36
|
+
NDVI (NIR ~850 nm, Red ~670 nm)
|
|
37
|
+
NDWI (Green ~560 nm, NIR ~850 nm)
|
|
38
|
+
EVI (NIR, Red, Blue ~490 nm)
|
|
39
|
+
SAVI (soil-adjusted vegetation index)
|
|
40
|
+
|
|
41
|
+
TEXTURE
|
|
42
|
+
GLCM on the first principal component image:
|
|
43
|
+
contrast, dissimilarity, homogeneity, energy, correlation, ASM
|
|
44
|
+
|
|
45
|
+
DIMENSIONALITY
|
|
46
|
+
PCA: explained variance per component (up to 20 components)
|
|
47
|
+
|
|
48
|
+
QUALITY
|
|
49
|
+
band dropout detection (bands with near-zero variance)
|
|
50
|
+
spectral smoothness (mean absolute difference between adjacent bands)
|
|
51
|
+
inter-band correlation (mean off-diagonal correlation)
|
|
52
|
+
|
|
53
|
+
Analyses — dataset level (multiple cubes)
|
|
54
|
+
-----------------------------------------
|
|
55
|
+
cross-cube mean / std spectrum (± envelope)
|
|
56
|
+
spectral diversity (pairwise cosine similarity)
|
|
57
|
+
distribution of per-cube scalar metrics
|
|
58
|
+
(brightness, contrast, SNR, NDVI mean, dynamic range …)
|
|
59
|
+
label / class distribution
|
|
60
|
+
band-count distribution
|
|
61
|
+
spatial size distribution
|
|
62
|
+
corrupt-file detection
|
|
63
|
+
|
|
64
|
+
Visualisations
|
|
65
|
+
--------------
|
|
66
|
+
plot() — single-cube deep-dive dashboard
|
|
67
|
+
plot_dataset() — dataset-level aggregate dashboard
|
|
68
|
+
plot_spectra() — mean spectra overlay for all cubes
|
|
69
|
+
plot_false_colour() — false-colour previews (grid)
|
|
70
|
+
plot_ndvi() — NDVI maps grid
|
|
71
|
+
plot_pca_components()— first N PC images
|
|
72
|
+
plot_band_stats() — per-band stats line chart
|
|
73
|
+
plot_spectral_diversity() — pairwise cosine similarity heatmap
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import hashlib
|
|
79
|
+
import warnings
|
|
80
|
+
from collections import Counter, defaultdict
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
83
|
+
|
|
84
|
+
import numpy as np
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── lazy imports ─────────────────────────────────────────────────────────────
|
|
88
|
+
def _plt():
|
|
89
|
+
import matplotlib.pyplot as plt; return plt
|
|
90
|
+
|
|
91
|
+
def _mpl():
|
|
92
|
+
import matplotlib as mpl; return mpl
|
|
93
|
+
|
|
94
|
+
def _ski():
|
|
95
|
+
from skimage.feature import graycomatrix, graycoprops
|
|
96
|
+
return graycomatrix, graycoprops
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
100
|
+
# Per-cube record
|
|
101
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
102
|
+
|
|
103
|
+
class CubeRecord:
|
|
104
|
+
"""All per-cube statistics in one lightweight object."""
|
|
105
|
+
|
|
106
|
+
__slots__ = (
|
|
107
|
+
# identity
|
|
108
|
+
"path", "label", "file_ext", "file_size_kb",
|
|
109
|
+
# shape
|
|
110
|
+
"height", "width", "bands", "dtype",
|
|
111
|
+
# global scalars
|
|
112
|
+
"global_mean", "global_std", "global_min", "global_max",
|
|
113
|
+
"dynamic_range",
|
|
114
|
+
# per-band arrays (length B each)
|
|
115
|
+
"band_means", "band_stds", "band_mins", "band_maxs",
|
|
116
|
+
"band_snr", "band_noise_mad", "band_saturation_frac",
|
|
117
|
+
# spectral quality
|
|
118
|
+
"spectral_smoothness", # mean |diff| between adjacent bands
|
|
119
|
+
"inter_band_corr", # mean off-diagonal correlation
|
|
120
|
+
"n_dropout_bands", # bands with std < dropout_thresh
|
|
121
|
+
"snr_mean", # scalar: mean SNR across bands
|
|
122
|
+
# spatial
|
|
123
|
+
"spatial_mean_map", # (H, W) mean across bands
|
|
124
|
+
"spatial_std_map", # (H, W) std across bands
|
|
125
|
+
# spectral indices
|
|
126
|
+
"ndvi_mean", "ndvi_std",
|
|
127
|
+
"ndwi_mean", "ndwi_std",
|
|
128
|
+
"evi_mean", "evi_std",
|
|
129
|
+
"savi_mean", "savi_std",
|
|
130
|
+
# PCA
|
|
131
|
+
"pca_variance_ratio", # array of length min(20, B)
|
|
132
|
+
# texture (on first PC)
|
|
133
|
+
"glcm_contrast", "glcm_dissimilarity",
|
|
134
|
+
"glcm_homogeneity", "glcm_energy",
|
|
135
|
+
"glcm_correlation", "glcm_asm",
|
|
136
|
+
# status
|
|
137
|
+
"is_corrupt",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def __init__(self):
|
|
141
|
+
for s in self.__slots__:
|
|
142
|
+
setattr(self, s, None)
|
|
143
|
+
self.is_corrupt = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
# Main class
|
|
148
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
class HyperspectralEDA:
|
|
151
|
+
"""
|
|
152
|
+
Comprehensive EDA for hyperspectral / multispectral datasets.
|
|
153
|
+
|
|
154
|
+
Works for a **single cube** or a **directory / list of cubes**.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
verbose : bool
|
|
159
|
+
Print progress messages.
|
|
160
|
+
wavelengths : array-like, optional
|
|
161
|
+
1-D array of wavelength values (nm) — one per band.
|
|
162
|
+
Enables real-axis spectral plots and automatic index computation.
|
|
163
|
+
max_cubes : int | None
|
|
164
|
+
Analyse at most *max_cubes* (useful for large datasets).
|
|
165
|
+
compute_glcm : bool
|
|
166
|
+
Compute GLCM texture on the first PC image (default True).
|
|
167
|
+
Requires ``scikit-image``.
|
|
168
|
+
compute_pca : bool
|
|
169
|
+
Compute PCA variance profile (default True).
|
|
170
|
+
Requires ``scikit-learn``.
|
|
171
|
+
dropout_threshold : float
|
|
172
|
+
Bands whose std is below this fraction of the global std are
|
|
173
|
+
flagged as "dropout" bands (default 0.01).
|
|
174
|
+
ndvi_nir_band / ndvi_red_band : int | None
|
|
175
|
+
Override auto-detected band indices for spectral index computation.
|
|
176
|
+
|
|
177
|
+
Examples
|
|
178
|
+
--------
|
|
179
|
+
Single cube from file
|
|
180
|
+
>>> eda = HyperspectralEDA(wavelengths=np.linspace(400,2500,200))
|
|
181
|
+
>>> eda.load("Indian_pines_corrected.mat")
|
|
182
|
+
>>> eda.summary()
|
|
183
|
+
>>> eda.plot()
|
|
184
|
+
|
|
185
|
+
Whole dataset from directory
|
|
186
|
+
>>> eda = HyperspectralEDA(wavelengths=np.linspace(400,2500,200))
|
|
187
|
+
>>> eda.load("path/to/cubes/", label_from_parent=True)
|
|
188
|
+
>>> eda.summary()
|
|
189
|
+
>>> eda.plot_dataset()
|
|
190
|
+
>>> eda.plot_spectra()
|
|
191
|
+
|
|
192
|
+
NumPy arrays directly
|
|
193
|
+
>>> eda = HyperspectralEDA()
|
|
194
|
+
>>> eda.load_arrays([cube1, cube2, cube3], labels=["A","B","C"])
|
|
195
|
+
>>> eda.plot_dataset()
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
SUPPORTED_EXTS = {".mat", ".npy", ".npz", ".hdr", ".bil", ".bip",
|
|
199
|
+
".bsq", ".envi", ".tif", ".tiff"}
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
verbose: bool = True,
|
|
204
|
+
wavelengths: Optional[np.ndarray] = None,
|
|
205
|
+
max_cubes: Optional[int] = None,
|
|
206
|
+
compute_glcm: bool = True,
|
|
207
|
+
compute_pca: bool = True,
|
|
208
|
+
dropout_threshold: float = 0.01,
|
|
209
|
+
ndvi_nir_band: Optional[int] = None,
|
|
210
|
+
ndvi_red_band: Optional[int] = None,
|
|
211
|
+
ndwi_green_band: Optional[int] = None,
|
|
212
|
+
ndwi_nir_band: Optional[int] = None,
|
|
213
|
+
):
|
|
214
|
+
self.verbose = verbose
|
|
215
|
+
self.wavelengths = np.asarray(wavelengths) if wavelengths is not None else None
|
|
216
|
+
self.max_cubes = max_cubes
|
|
217
|
+
self.compute_glcm = compute_glcm
|
|
218
|
+
self.compute_pca = compute_pca
|
|
219
|
+
self.dropout_threshold = dropout_threshold
|
|
220
|
+
self._ndvi_nir = ndvi_nir_band
|
|
221
|
+
self._ndvi_red = ndvi_red_band
|
|
222
|
+
self._ndwi_green = ndwi_green_band
|
|
223
|
+
self._ndwi_nir = ndwi_nir_band
|
|
224
|
+
|
|
225
|
+
self._records: List[CubeRecord] = []
|
|
226
|
+
self._label_map: Dict[str, str] = {}
|
|
227
|
+
self._loaded = False
|
|
228
|
+
self._results: Dict[str, Any] = {}
|
|
229
|
+
|
|
230
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
231
|
+
# Loading
|
|
232
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
def load(
|
|
235
|
+
self,
|
|
236
|
+
source: Union[str, Path, List],
|
|
237
|
+
labels: Optional[Dict[str, str]] = None,
|
|
238
|
+
label_from_parent: bool = False,
|
|
239
|
+
recursive: bool = True,
|
|
240
|
+
) -> "HyperspectralEDA":
|
|
241
|
+
"""
|
|
242
|
+
Load one or many hyperspectral cubes from files.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
source
|
|
247
|
+
A directory, a single file, or a list of file paths.
|
|
248
|
+
labels
|
|
249
|
+
``{path: label}`` mapping.
|
|
250
|
+
label_from_parent
|
|
251
|
+
Use the parent folder name as the cube's label.
|
|
252
|
+
recursive
|
|
253
|
+
Recurse into sub-directories when *source* is a folder.
|
|
254
|
+
"""
|
|
255
|
+
paths = self._resolve_paths(source, recursive)
|
|
256
|
+
if self.max_cubes:
|
|
257
|
+
paths = paths[: self.max_cubes]
|
|
258
|
+
|
|
259
|
+
if labels:
|
|
260
|
+
self._label_map = {str(Path(k).resolve()): v
|
|
261
|
+
for k, v in labels.items()}
|
|
262
|
+
|
|
263
|
+
self._log(f"Found {len(paths)} cube file(s) — computing statistics …")
|
|
264
|
+
self._records = []
|
|
265
|
+
|
|
266
|
+
for i, p in enumerate(paths):
|
|
267
|
+
if self.verbose and i % max(1, len(paths) // 20) == 0:
|
|
268
|
+
self._log(f" [{i:>{len(str(len(paths)))}}/{len(paths)}] {p.name}")
|
|
269
|
+
rec = self._analyse_file(p, label_from_parent)
|
|
270
|
+
self._records.append(rec)
|
|
271
|
+
|
|
272
|
+
self._loaded = True
|
|
273
|
+
n_bad = sum(r.is_corrupt for r in self._records)
|
|
274
|
+
self._log(f"Done. {len(self._records)} cube(s) loaded ({n_bad} corrupt).")
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def load_arrays(
|
|
278
|
+
self,
|
|
279
|
+
arrays: List[np.ndarray],
|
|
280
|
+
labels: Optional[List[str]] = None,
|
|
281
|
+
) -> "HyperspectralEDA":
|
|
282
|
+
"""
|
|
283
|
+
Load cubes directly as NumPy arrays of shape ``(H, W, B)``.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
arrays
|
|
288
|
+
List of hyperspectral cubes.
|
|
289
|
+
labels
|
|
290
|
+
Optional label for each cube.
|
|
291
|
+
"""
|
|
292
|
+
self._log(f"Loading {len(arrays)} array(s) …")
|
|
293
|
+
self._records = []
|
|
294
|
+
for i, arr in enumerate(arrays):
|
|
295
|
+
rec = CubeRecord()
|
|
296
|
+
rec.path = f"<array_{i}>"
|
|
297
|
+
rec.label = labels[i] if labels and i < len(labels) else None
|
|
298
|
+
rec.file_ext = "array"
|
|
299
|
+
try:
|
|
300
|
+
self._fill_stats(rec, arr.astype(np.float32))
|
|
301
|
+
except Exception as e:
|
|
302
|
+
rec.is_corrupt = True
|
|
303
|
+
self._log(f" ✗ array_{i}: {e}")
|
|
304
|
+
self._records.append(rec)
|
|
305
|
+
self._loaded = True
|
|
306
|
+
return self
|
|
307
|
+
|
|
308
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
309
|
+
# Summary
|
|
310
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def summary(self) -> Dict[str, Any]:
|
|
313
|
+
"""
|
|
314
|
+
Return a comprehensive summary dictionary.
|
|
315
|
+
|
|
316
|
+
Sections: inventory, spatial, spectral_stats, spectral_quality,
|
|
317
|
+
spectral_indices, texture, pca, dataset_stats, labels
|
|
318
|
+
"""
|
|
319
|
+
self._check_loaded()
|
|
320
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
321
|
+
corrupt = [r for r in self._records if r.is_corrupt]
|
|
322
|
+
|
|
323
|
+
if not valid:
|
|
324
|
+
return {"error": "No valid cubes found."}
|
|
325
|
+
|
|
326
|
+
def arr(attr):
|
|
327
|
+
return np.array([getattr(r, attr) for r in valid
|
|
328
|
+
if getattr(r, attr) is not None])
|
|
329
|
+
|
|
330
|
+
# ── inventory ────────────────────────────────────────────────
|
|
331
|
+
band_dist = dict(Counter(r.bands for r in valid))
|
|
332
|
+
label_dist = None
|
|
333
|
+
if any(r.label for r in valid):
|
|
334
|
+
lc = Counter(r.label for r in valid)
|
|
335
|
+
label_dist = dict(lc)
|
|
336
|
+
|
|
337
|
+
# ── cross-cube mean spectrum ──────────────────────────────────
|
|
338
|
+
dom_bands = int(Counter(r.bands for r in valid).most_common(1)[0][0])
|
|
339
|
+
matching = [r for r in valid if r.bands == dom_bands
|
|
340
|
+
and r.band_means is not None]
|
|
341
|
+
cross_mean = None; cross_std = None
|
|
342
|
+
if matching:
|
|
343
|
+
stack = np.stack([r.band_means for r in matching])
|
|
344
|
+
cross_mean = stack.mean(axis=0).tolist()
|
|
345
|
+
cross_std = stack.std(axis=0).tolist()
|
|
346
|
+
|
|
347
|
+
# ── per-band stats averaged across dataset ────────────────────
|
|
348
|
+
if matching:
|
|
349
|
+
snr_stack = np.stack([r.band_snr for r in matching
|
|
350
|
+
if r.band_snr is not None])
|
|
351
|
+
noise_stack= np.stack([r.band_noise_mad for r in matching
|
|
352
|
+
if r.band_noise_mad is not None])
|
|
353
|
+
mean_snr_per_band = snr_stack.mean(axis=0).tolist()
|
|
354
|
+
mean_noise_per_band= noise_stack.mean(axis=0).tolist()
|
|
355
|
+
else:
|
|
356
|
+
mean_snr_per_band = []
|
|
357
|
+
mean_noise_per_band = []
|
|
358
|
+
|
|
359
|
+
# ── spectral index summary ────────────────────────────────────
|
|
360
|
+
indices = {}
|
|
361
|
+
for idx_name in ("ndvi", "ndwi", "evi", "savi"):
|
|
362
|
+
means = arr(f"{idx_name}_mean")
|
|
363
|
+
stds = arr(f"{idx_name}_std")
|
|
364
|
+
if len(means):
|
|
365
|
+
indices[idx_name] = {
|
|
366
|
+
"per_cube_mean": _stat_dict(means),
|
|
367
|
+
"per_cube_std": _stat_dict(stds),
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# ── texture summary ───────────────────────────────────────────
|
|
371
|
+
texture = {}
|
|
372
|
+
for feat in ("glcm_contrast","glcm_dissimilarity","glcm_homogeneity",
|
|
373
|
+
"glcm_energy","glcm_correlation","glcm_asm"):
|
|
374
|
+
a = arr(feat)
|
|
375
|
+
if len(a):
|
|
376
|
+
texture[feat] = _stat_dict(a)
|
|
377
|
+
|
|
378
|
+
# ── PCA summary ───────────────────────────────────────────────
|
|
379
|
+
pca_vars = [r.pca_variance_ratio for r in valid
|
|
380
|
+
if r.pca_variance_ratio is not None]
|
|
381
|
+
pca_summary = {}
|
|
382
|
+
if pca_vars:
|
|
383
|
+
min_len = min(len(v) for v in pca_vars)
|
|
384
|
+
pca_stack = np.stack([v[:min_len] for v in pca_vars])
|
|
385
|
+
pca_summary = {
|
|
386
|
+
"mean_variance_ratio": pca_stack.mean(axis=0).tolist(),
|
|
387
|
+
"n_components_95pct_mean": int(np.searchsorted(
|
|
388
|
+
pca_stack.mean(axis=0).cumsum(), 0.95) + 1),
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
result = {
|
|
392
|
+
"inventory": {
|
|
393
|
+
"total_cubes": len(self._records),
|
|
394
|
+
"valid_cubes": len(valid),
|
|
395
|
+
"corrupt_cubes": len(corrupt),
|
|
396
|
+
"corrupt_paths": [r.path for r in corrupt],
|
|
397
|
+
"band_distribution": band_dist,
|
|
398
|
+
"format_distribution": dict(Counter(
|
|
399
|
+
r.file_ext for r in valid)),
|
|
400
|
+
"label_distribution": label_dist,
|
|
401
|
+
},
|
|
402
|
+
"spatial": {
|
|
403
|
+
"height": _stat_dict(arr("height")),
|
|
404
|
+
"width": _stat_dict(arr("width")),
|
|
405
|
+
"bands": _stat_dict(np.array([r.bands for r in valid])),
|
|
406
|
+
"file_size_kb": _stat_dict(arr("file_size_kb")),
|
|
407
|
+
},
|
|
408
|
+
"spectral_stats": {
|
|
409
|
+
"global_mean": _stat_dict(arr("global_mean")),
|
|
410
|
+
"global_std": _stat_dict(arr("global_std")),
|
|
411
|
+
"dynamic_range": _stat_dict(arr("dynamic_range")),
|
|
412
|
+
"cross_cube_mean_spectrum": cross_mean,
|
|
413
|
+
"cross_cube_std_spectrum": cross_std,
|
|
414
|
+
"dataset_mean_snr_per_band": mean_snr_per_band,
|
|
415
|
+
"dataset_mean_noise_per_band": mean_noise_per_band,
|
|
416
|
+
"dominant_band_count": dom_bands,
|
|
417
|
+
"n_matching_cubes": len(matching),
|
|
418
|
+
},
|
|
419
|
+
"spectral_quality": {
|
|
420
|
+
"snr_mean": _stat_dict(arr("snr_mean")),
|
|
421
|
+
"spectral_smoothness":_stat_dict(arr("spectral_smoothness")),
|
|
422
|
+
"inter_band_corr": _stat_dict(arr("inter_band_corr")),
|
|
423
|
+
"n_dropout_bands": _stat_dict(arr("n_dropout_bands")),
|
|
424
|
+
},
|
|
425
|
+
"spectral_indices": indices,
|
|
426
|
+
"texture": texture,
|
|
427
|
+
"pca": pca_summary,
|
|
428
|
+
"labels": {
|
|
429
|
+
"label_distribution": label_dist,
|
|
430
|
+
"class_imbalance_ratio": (
|
|
431
|
+
round(max(Counter(r.label for r in valid).values()) /
|
|
432
|
+
max(min(Counter(r.label for r in valid).values()), 1), 3)
|
|
433
|
+
if label_dist and len(label_dist) > 1 else None
|
|
434
|
+
),
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
self._results["summary"] = result
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
441
|
+
# Single-cube accessors
|
|
442
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
def get_record(self, index: int = 0) -> CubeRecord:
|
|
445
|
+
"""Return the CubeRecord for cube at *index*."""
|
|
446
|
+
self._check_loaded()
|
|
447
|
+
return self._records[index]
|
|
448
|
+
|
|
449
|
+
def spectral_signature(
|
|
450
|
+
self, cube_index: int = 0, row: int = 0, col: int = 0
|
|
451
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
452
|
+
"""
|
|
453
|
+
Return (wavelengths, reflectance) for a single pixel in a cube.
|
|
454
|
+
|
|
455
|
+
Parameters
|
|
456
|
+
----------
|
|
457
|
+
cube_index : int
|
|
458
|
+
Which loaded cube to use.
|
|
459
|
+
row, col : int
|
|
460
|
+
Pixel coordinates within that cube.
|
|
461
|
+
"""
|
|
462
|
+
self._check_loaded()
|
|
463
|
+
rec = self._records[cube_index]
|
|
464
|
+
if rec.is_corrupt or rec.path.startswith("<array"):
|
|
465
|
+
raise ValueError("Cannot read pixel from corrupt or in-memory cube.")
|
|
466
|
+
cube = self._read_cube(Path(rec.path))
|
|
467
|
+
wl = (self.wavelengths if self.wavelengths is not None
|
|
468
|
+
else np.arange(cube.shape[2]))
|
|
469
|
+
return wl, cube[row, col, :].copy()
|
|
470
|
+
|
|
471
|
+
def compute_index(
|
|
472
|
+
self,
|
|
473
|
+
cube_index: int = 0,
|
|
474
|
+
index_name: str = "ndvi",
|
|
475
|
+
) -> np.ndarray:
|
|
476
|
+
"""
|
|
477
|
+
Compute a spectral index map for a single cube.
|
|
478
|
+
|
|
479
|
+
Parameters
|
|
480
|
+
----------
|
|
481
|
+
index_name : ``"ndvi"`` | ``"ndwi"`` | ``"evi"`` | ``"savi"``
|
|
482
|
+
"""
|
|
483
|
+
self._check_loaded()
|
|
484
|
+
rec = self._records[cube_index]
|
|
485
|
+
cube = self._load_cube_array(rec)
|
|
486
|
+
return self._compute_index(cube, index_name)
|
|
487
|
+
|
|
488
|
+
def pca_scores(
|
|
489
|
+
self,
|
|
490
|
+
cube_index: int = 0,
|
|
491
|
+
n_components: int = 10,
|
|
492
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
493
|
+
"""
|
|
494
|
+
Run PCA on a single cube's spectral dimension.
|
|
495
|
+
|
|
496
|
+
Returns
|
|
497
|
+
-------
|
|
498
|
+
scores : ndarray (H*W, n_components)
|
|
499
|
+
variance_ratio : ndarray (n_components,)
|
|
500
|
+
"""
|
|
501
|
+
self._check_loaded()
|
|
502
|
+
from sklearn.decomposition import PCA
|
|
503
|
+
rec = self._records[cube_index]
|
|
504
|
+
cube = self._load_cube_array(rec)
|
|
505
|
+
H, W, B = cube.shape
|
|
506
|
+
X = cube.reshape(-1, B)
|
|
507
|
+
pca = PCA(n_components=min(n_components, B), svd_solver="randomized")
|
|
508
|
+
scores = pca.fit_transform(X)
|
|
509
|
+
return scores, pca.explained_variance_ratio_
|
|
510
|
+
|
|
511
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
512
|
+
# Plotting — single cube deep-dive
|
|
513
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
def plot(
|
|
516
|
+
self,
|
|
517
|
+
cube_index: int = 0,
|
|
518
|
+
figsize: Tuple[int, int] = (22, 20),
|
|
519
|
+
save_path: Optional[str] = None,
|
|
520
|
+
dpi: int = 150,
|
|
521
|
+
rgb_bands: Optional[Tuple[int, int, int]] = None,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""
|
|
524
|
+
Single-cube deep-dive dashboard (5 rows × 3 cols).
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
cube_index
|
|
529
|
+
Which loaded cube to display (default 0).
|
|
530
|
+
rgb_bands
|
|
531
|
+
Band indices for false-colour preview (R, G, B).
|
|
532
|
+
Auto-selected if not provided.
|
|
533
|
+
"""
|
|
534
|
+
self._check_loaded()
|
|
535
|
+
plt = _plt(); mpl = _mpl()
|
|
536
|
+
rec = self._records[cube_index]
|
|
537
|
+
if rec.is_corrupt:
|
|
538
|
+
self._log(f"Cube {cube_index} is corrupt — cannot plot.")
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
cube = self._load_cube_array(rec)
|
|
542
|
+
H, W, B = cube.shape
|
|
543
|
+
wl = self.wavelengths if self.wavelengths is not None else np.arange(B)
|
|
544
|
+
|
|
545
|
+
if rgb_bands is None:
|
|
546
|
+
step = max(1, B // 3)
|
|
547
|
+
rgb_bands = (min(2 * step, B-1), min(step, B-1), 0)
|
|
548
|
+
|
|
549
|
+
fig = plt.figure(figsize=figsize, facecolor="white")
|
|
550
|
+
title = (f"HyperspectralEDA — {Path(rec.path).name}"
|
|
551
|
+
if not rec.path.startswith("<array") else
|
|
552
|
+
f"HyperspectralEDA — {rec.path}")
|
|
553
|
+
fig.suptitle(title, fontsize=18, color="#1f2328",
|
|
554
|
+
y=0.99, fontweight="bold")
|
|
555
|
+
|
|
556
|
+
gs = mpl.gridspec.GridSpec(5, 3, figure=fig,
|
|
557
|
+
hspace=0.55, wspace=0.35,
|
|
558
|
+
left=0.06, right=0.97,
|
|
559
|
+
top=0.96, bottom=0.03)
|
|
560
|
+
|
|
561
|
+
def ax(*args, **kw):
|
|
562
|
+
a = fig.add_subplot(*args, **kw)
|
|
563
|
+
a.set_facecolor("#f6f8fa")
|
|
564
|
+
a.tick_params(colors="#57606a", labelsize=8)
|
|
565
|
+
for sp in a.spines.values():
|
|
566
|
+
sp.set_edgecolor("#d0d7de")
|
|
567
|
+
return a
|
|
568
|
+
|
|
569
|
+
# ── Row 0: info card + false colour + spatial mean ────────────
|
|
570
|
+
self._plot_cube_info(ax(gs[0, 0]), rec, cube)
|
|
571
|
+
self._plot_false_colour(ax(gs[0, 1]), cube, rgb_bands,
|
|
572
|
+
title="False Colour (R/G/B bands)")
|
|
573
|
+
self._plot_spatial_mean(ax(gs[0, 2]), rec)
|
|
574
|
+
|
|
575
|
+
# ── Row 1: mean spectrum + band means + band stds ─────────────
|
|
576
|
+
self._plot_mean_spectrum_single(ax(gs[1, :2]), rec, wl)
|
|
577
|
+
self._plot_band_snr(ax(gs[1, 2]), rec, wl)
|
|
578
|
+
|
|
579
|
+
# ── Row 2: band stats ─────────────────────────────────────────
|
|
580
|
+
self._plot_band_stats_lines(ax(gs[2, :]), rec, wl)
|
|
581
|
+
|
|
582
|
+
# ── Row 3: spectral indices ───────────────────────────────────
|
|
583
|
+
idx_names = ["ndvi", "ndwi", "evi", "savi"]
|
|
584
|
+
for col, name in enumerate(idx_names[:3]):
|
|
585
|
+
self._plot_index_map_single(ax(gs[3, col]), cube, name)
|
|
586
|
+
|
|
587
|
+
# ── Row 4: PCA variance + GLCM radar + dropout ───────────────
|
|
588
|
+
self._plot_pca_variance_single(ax(gs[4, 0]), rec)
|
|
589
|
+
self._plot_glcm_radar(ax(gs[4, 1]), [rec])
|
|
590
|
+
self._plot_spectral_quality_bars(ax(gs[4, 2]), [rec])
|
|
591
|
+
|
|
592
|
+
self._finalise(fig, save_path, dpi)
|
|
593
|
+
|
|
594
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
595
|
+
# Plotting — dataset-level dashboard
|
|
596
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
597
|
+
|
|
598
|
+
def plot_dataset(
|
|
599
|
+
self,
|
|
600
|
+
figsize: Tuple[int, int] = (24, 24),
|
|
601
|
+
save_path: Optional[str] = None,
|
|
602
|
+
dpi: int = 150,
|
|
603
|
+
) -> None:
|
|
604
|
+
"""
|
|
605
|
+
Dataset-level aggregate dashboard (6 rows × 4 cols).
|
|
606
|
+
Shows distributions across ALL loaded cubes.
|
|
607
|
+
"""
|
|
608
|
+
self._check_loaded()
|
|
609
|
+
plt = _plt(); mpl = _mpl()
|
|
610
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
611
|
+
|
|
612
|
+
fig = plt.figure(figsize=figsize, facecolor="white")
|
|
613
|
+
fig.suptitle("HyperspectralEDA — Dataset Analysis",
|
|
614
|
+
fontsize=20, color="#1f2328",
|
|
615
|
+
y=0.99, fontweight="bold")
|
|
616
|
+
|
|
617
|
+
gs = mpl.gridspec.GridSpec(6, 4, figure=fig,
|
|
618
|
+
hspace=0.55, wspace=0.35,
|
|
619
|
+
left=0.06, right=0.97,
|
|
620
|
+
top=0.96, bottom=0.02)
|
|
621
|
+
|
|
622
|
+
def ax(*args, **kw):
|
|
623
|
+
a = fig.add_subplot(*args, **kw)
|
|
624
|
+
a.set_facecolor("#f6f8fa")
|
|
625
|
+
a.tick_params(colors="#57606a", labelsize=8)
|
|
626
|
+
for sp in a.spines.values():
|
|
627
|
+
sp.set_edgecolor("#d0d7de")
|
|
628
|
+
return a
|
|
629
|
+
|
|
630
|
+
s = self.summary()
|
|
631
|
+
|
|
632
|
+
# ── Row 0: overview card + label dist ─────────────────────────
|
|
633
|
+
self._plot_dataset_info(ax(gs[0, :2]), valid, s)
|
|
634
|
+
self._plot_label_dist(ax(gs[0, 2:]), valid)
|
|
635
|
+
|
|
636
|
+
# ── Row 1: spatial & band distributions ───────────────────────
|
|
637
|
+
self._plot_hist(ax(gs[1, 0]), [r.height for r in valid],
|
|
638
|
+
"Heights (px)", "#58a6ff")
|
|
639
|
+
self._plot_hist(ax(gs[1, 1]), [r.width for r in valid],
|
|
640
|
+
"Widths (px)", "#3fb950")
|
|
641
|
+
self._plot_hist(ax(gs[1, 2]), [r.bands for r in valid],
|
|
642
|
+
"Band Count", "#d2a8ff")
|
|
643
|
+
self._plot_hist(ax(gs[1, 3]),
|
|
644
|
+
[r.file_size_kb for r in valid if r.file_size_kb],
|
|
645
|
+
"File Size (KB)", "#ffa657")
|
|
646
|
+
|
|
647
|
+
# ── Row 2: cross-cube mean spectrum ───────────────────────────
|
|
648
|
+
self._plot_cross_spectrum(ax(gs[2, :3]), s)
|
|
649
|
+
self._plot_band_count_dist(ax(gs[2, 3]), valid)
|
|
650
|
+
|
|
651
|
+
# ── Row 3: scalar quality distributions ───────────────────────
|
|
652
|
+
self._plot_hist(ax(gs[3, 0]),
|
|
653
|
+
[r.snr_mean for r in valid if r.snr_mean],
|
|
654
|
+
"Mean SNR (per cube)", "#e3b341")
|
|
655
|
+
self._plot_hist(ax(gs[3, 1]),
|
|
656
|
+
[r.dynamic_range for r in valid if r.dynamic_range],
|
|
657
|
+
"Dynamic Range", "#79c0ff")
|
|
658
|
+
self._plot_hist(ax(gs[3, 2]),
|
|
659
|
+
[r.spectral_smoothness for r in valid
|
|
660
|
+
if r.spectral_smoothness],
|
|
661
|
+
"Spectral Smoothness", "#56d364")
|
|
662
|
+
self._plot_hist(ax(gs[3, 3]),
|
|
663
|
+
[r.inter_band_corr for r in valid
|
|
664
|
+
if r.inter_band_corr],
|
|
665
|
+
"Inter-band Correlation", "#f78166")
|
|
666
|
+
|
|
667
|
+
# ── Row 4: spectral indices distributions ─────────────────────
|
|
668
|
+
idx_colors = {"ndvi": "#3fb950", "ndwi": "#58a6ff",
|
|
669
|
+
"evi": "#e3b341", "savi": "#f78166"}
|
|
670
|
+
for col, (name, color) in enumerate(idx_colors.items()):
|
|
671
|
+
vals = [getattr(r, f"{name}_mean") for r in valid
|
|
672
|
+
if getattr(r, f"{name}_mean") is not None]
|
|
673
|
+
self._plot_hist(ax(gs[4, col]), vals,
|
|
674
|
+
f"{name.upper()} Mean (per cube)", color)
|
|
675
|
+
|
|
676
|
+
# ── Row 5: spectral diversity + PCA + dropout ─────────────────
|
|
677
|
+
self._plot_spectral_diversity(ax(gs[5, :2]), valid)
|
|
678
|
+
self._plot_pca_variance_dataset(ax(gs[5, 2]), valid)
|
|
679
|
+
self._plot_hist(ax(gs[5, 3]),
|
|
680
|
+
[r.n_dropout_bands for r in valid
|
|
681
|
+
if r.n_dropout_bands is not None],
|
|
682
|
+
"Dropout Bands (per cube)", "#ff6b6b")
|
|
683
|
+
|
|
684
|
+
self._finalise(fig, save_path, dpi)
|
|
685
|
+
|
|
686
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
687
|
+
# Plotting — spectra overlay
|
|
688
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
def plot_spectra(
|
|
691
|
+
self,
|
|
692
|
+
max_cubes: int = 20,
|
|
693
|
+
figsize: Tuple[int, int] = (14, 6),
|
|
694
|
+
save_path: Optional[str] = None,
|
|
695
|
+
dpi: int = 150,
|
|
696
|
+
) -> None:
|
|
697
|
+
"""
|
|
698
|
+
Overlay mean spectra for all (or up to *max_cubes*) loaded cubes.
|
|
699
|
+
Each curve is coloured by label if labels are available.
|
|
700
|
+
"""
|
|
701
|
+
self._check_loaded()
|
|
702
|
+
plt = _plt()
|
|
703
|
+
valid = [r for r in self._records if not r.is_corrupt
|
|
704
|
+
and r.band_means is not None][:max_cubes]
|
|
705
|
+
|
|
706
|
+
dom_bands = Counter(r.bands for r in valid).most_common(1)[0][0]
|
|
707
|
+
matching = [r for r in valid if r.bands == dom_bands]
|
|
708
|
+
wl = (self.wavelengths[:dom_bands]
|
|
709
|
+
if self.wavelengths is not None else np.arange(dom_bands))
|
|
710
|
+
|
|
711
|
+
fig, ax = plt.subplots(figsize=figsize, facecolor="white")
|
|
712
|
+
ax.set_facecolor("#f6f8fa")
|
|
713
|
+
for sp in ax.spines.values():
|
|
714
|
+
sp.set_edgecolor("#d0d7de")
|
|
715
|
+
ax.tick_params(colors="#57606a", labelsize=8)
|
|
716
|
+
|
|
717
|
+
labels_present = any(r.label for r in matching)
|
|
718
|
+
all_labels = sorted(set(r.label for r in matching if r.label))
|
|
719
|
+
cmap = plt.cm.get_cmap("tab10", max(len(all_labels), 1))
|
|
720
|
+
label_color = {lbl: cmap(i) for i, lbl in enumerate(all_labels)}
|
|
721
|
+
legend_handles = {}
|
|
722
|
+
|
|
723
|
+
for rec in matching:
|
|
724
|
+
color = label_color.get(rec.label, "#57606a") \
|
|
725
|
+
if labels_present else "#58a6ff"
|
|
726
|
+
lbl = rec.label or Path(rec.path).stem[:20]
|
|
727
|
+
line, = ax.plot(wl, rec.band_means, lw=1, alpha=0.7,
|
|
728
|
+
color=color, label=lbl)
|
|
729
|
+
if rec.label and rec.label not in legend_handles:
|
|
730
|
+
legend_handles[rec.label] = line
|
|
731
|
+
|
|
732
|
+
if legend_handles:
|
|
733
|
+
ax.legend(legend_handles.values(), legend_handles.keys(),
|
|
734
|
+
fontsize=7, labelcolor="#1f2328",
|
|
735
|
+
facecolor="white", edgecolor="#d0d7de",
|
|
736
|
+
loc="upper right")
|
|
737
|
+
|
|
738
|
+
ax.set_title(f"Mean Spectral Signatures ({len(matching)} cubes)",
|
|
739
|
+
color="#1f2328", fontsize=12)
|
|
740
|
+
xlabel = "Wavelength (nm)" if self.wavelengths is not None else "Band index"
|
|
741
|
+
ax.set_xlabel(xlabel, color="#57606a", fontsize=9)
|
|
742
|
+
ax.set_ylabel("Mean Reflectance", color="#57606a", fontsize=9)
|
|
743
|
+
fig.suptitle("HyperspectralEDA — Spectral Overlay",
|
|
744
|
+
color="#1f2328", fontsize=14, fontweight="bold")
|
|
745
|
+
self._finalise(fig, save_path, dpi)
|
|
746
|
+
|
|
747
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
748
|
+
# Plotting — false colour grid
|
|
749
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
def plot_false_colour(
|
|
752
|
+
self,
|
|
753
|
+
n: int = 12,
|
|
754
|
+
cols: int = 4,
|
|
755
|
+
rgb_bands: Optional[Tuple[int, int, int]] = None,
|
|
756
|
+
figsize: Optional[Tuple] = None,
|
|
757
|
+
save_path: Optional[str] = None,
|
|
758
|
+
dpi: int = 150,
|
|
759
|
+
) -> None:
|
|
760
|
+
"""Grid of false-colour previews for up to *n* cubes."""
|
|
761
|
+
self._check_loaded()
|
|
762
|
+
plt = _plt()
|
|
763
|
+
valid = [r for r in self._records if not r.is_corrupt][:n]
|
|
764
|
+
rows = int(np.ceil(len(valid) / cols))
|
|
765
|
+
figsize = figsize or (cols * 4, rows * 3.5)
|
|
766
|
+
|
|
767
|
+
fig, axes = plt.subplots(rows, cols, figsize=figsize,
|
|
768
|
+
facecolor="white", squeeze=False)
|
|
769
|
+
|
|
770
|
+
for i, rec in enumerate(valid):
|
|
771
|
+
ax = axes[i // cols][i % cols]
|
|
772
|
+
ax.axis("off")
|
|
773
|
+
try:
|
|
774
|
+
cube = self._load_cube_array(rec)
|
|
775
|
+
B = cube.shape[2]
|
|
776
|
+
if rgb_bands is None:
|
|
777
|
+
step = max(1, B // 3)
|
|
778
|
+
rb = (min(2*step, B-1), min(step, B-1), 0)
|
|
779
|
+
else:
|
|
780
|
+
rb = rgb_bands
|
|
781
|
+
fc = cube[:, :, list(rb)].astype(np.float32)
|
|
782
|
+
fc = (fc - fc.min()) / (fc.max() - fc.min() + 1e-9)
|
|
783
|
+
ax.imshow(np.clip(fc, 0, 1))
|
|
784
|
+
title = (rec.label or Path(rec.path).stem)[:22]
|
|
785
|
+
ax.set_title(title, fontsize=7, color="#1f2328")
|
|
786
|
+
except Exception as e:
|
|
787
|
+
ax.text(0.5, 0.5, f"Error\n{e}", ha="center", va="center",
|
|
788
|
+
fontsize=7, color="#f78166",
|
|
789
|
+
transform=ax.transAxes)
|
|
790
|
+
|
|
791
|
+
for j in range(len(valid), rows * cols):
|
|
792
|
+
axes[j // cols][j % cols].axis("off")
|
|
793
|
+
|
|
794
|
+
fig.suptitle("False Colour Previews", color="#1f2328",
|
|
795
|
+
fontsize=14, fontweight="bold")
|
|
796
|
+
plt.tight_layout()
|
|
797
|
+
self._finalise(fig, save_path, dpi)
|
|
798
|
+
|
|
799
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
800
|
+
# Plotting — NDVI grid
|
|
801
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
def plot_ndvi(
|
|
804
|
+
self,
|
|
805
|
+
n: int = 12,
|
|
806
|
+
cols: int = 4,
|
|
807
|
+
figsize: Optional[Tuple] = None,
|
|
808
|
+
save_path: Optional[str] = None,
|
|
809
|
+
dpi: int = 150,
|
|
810
|
+
) -> None:
|
|
811
|
+
"""Grid of NDVI maps for up to *n* cubes."""
|
|
812
|
+
self._check_loaded()
|
|
813
|
+
plt = _plt()
|
|
814
|
+
valid = [r for r in self._records if not r.is_corrupt][:n]
|
|
815
|
+
rows = int(np.ceil(len(valid) / cols))
|
|
816
|
+
figsize = figsize or (cols * 4, rows * 3.5)
|
|
817
|
+
|
|
818
|
+
fig, axes = plt.subplots(rows, cols, figsize=figsize,
|
|
819
|
+
facecolor="white", squeeze=False)
|
|
820
|
+
|
|
821
|
+
for i, rec in enumerate(valid):
|
|
822
|
+
ax = axes[i // cols][i % cols]
|
|
823
|
+
ax.axis("off")
|
|
824
|
+
try:
|
|
825
|
+
cube = self._load_cube_array(rec)
|
|
826
|
+
ndvi = self._compute_index(cube, "ndvi")
|
|
827
|
+
im = ax.imshow(ndvi, cmap="RdYlGn", vmin=-1, vmax=1)
|
|
828
|
+
plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04,
|
|
829
|
+
).ax.tick_params(labelsize=6)
|
|
830
|
+
title = (rec.label or Path(rec.path).stem)[:22]
|
|
831
|
+
ax.set_title(
|
|
832
|
+
f"{title}\nμ={rec.ndvi_mean:.3f}" if rec.ndvi_mean else title,
|
|
833
|
+
fontsize=7, color="#1f2328")
|
|
834
|
+
except Exception as e:
|
|
835
|
+
ax.text(0.5, 0.5, f"No NDVI\n{e}", ha="center", va="center",
|
|
836
|
+
fontsize=7, color="#f78166", transform=ax.transAxes)
|
|
837
|
+
|
|
838
|
+
for j in range(len(valid), rows * cols):
|
|
839
|
+
axes[j // cols][j % cols].axis("off")
|
|
840
|
+
|
|
841
|
+
fig.suptitle("NDVI Maps", color="#1f2328",
|
|
842
|
+
fontsize=14, fontweight="bold")
|
|
843
|
+
plt.tight_layout()
|
|
844
|
+
self._finalise(fig, save_path, dpi)
|
|
845
|
+
|
|
846
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
847
|
+
# Plotting — PCA component images
|
|
848
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
def plot_pca_components(
|
|
851
|
+
self,
|
|
852
|
+
cube_index: int = 0,
|
|
853
|
+
n_components: int = 6,
|
|
854
|
+
figsize: Optional[Tuple] = None,
|
|
855
|
+
save_path: Optional[str] = None,
|
|
856
|
+
dpi: int = 150,
|
|
857
|
+
) -> None:
|
|
858
|
+
"""Visualise the first *n_components* PCA component images."""
|
|
859
|
+
self._check_loaded()
|
|
860
|
+
plt = _plt()
|
|
861
|
+
rec = self._records[cube_index]
|
|
862
|
+
cube = self._load_cube_array(rec)
|
|
863
|
+
H, W, B = cube.shape
|
|
864
|
+
scores, var = self.pca_scores(cube_index, n_components)
|
|
865
|
+
|
|
866
|
+
cols = min(n_components, 3)
|
|
867
|
+
rows = int(np.ceil(n_components / cols))
|
|
868
|
+
figsize = figsize or (cols * 4, rows * 3.5 + 1)
|
|
869
|
+
|
|
870
|
+
fig, axes = plt.subplots(rows, cols, figsize=figsize,
|
|
871
|
+
facecolor="white", squeeze=False)
|
|
872
|
+
|
|
873
|
+
for i in range(n_components):
|
|
874
|
+
comp_img = scores[:, i].reshape(H, W)
|
|
875
|
+
ax = axes[i // cols][i % cols]
|
|
876
|
+
ax.axis("off")
|
|
877
|
+
im = ax.imshow(comp_img, cmap="RdBu_r")
|
|
878
|
+
plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04
|
|
879
|
+
).ax.tick_params(labelsize=6)
|
|
880
|
+
ax.set_title(f"PC{i+1} ({var[i]*100:.1f}%)",
|
|
881
|
+
fontsize=8, color="#1f2328")
|
|
882
|
+
|
|
883
|
+
for j in range(n_components, rows * cols):
|
|
884
|
+
axes[j // cols][j % cols].axis("off")
|
|
885
|
+
|
|
886
|
+
title = (rec.label or Path(rec.path).stem)[:30]
|
|
887
|
+
fig.suptitle(f"PCA Components — {title}",
|
|
888
|
+
color="#1f2328", fontsize=13, fontweight="bold")
|
|
889
|
+
plt.tight_layout()
|
|
890
|
+
self._finalise(fig, save_path, dpi)
|
|
891
|
+
|
|
892
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
893
|
+
# Plotting — band statistics line chart
|
|
894
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
def plot_band_stats(
|
|
897
|
+
self,
|
|
898
|
+
cube_index: int = 0,
|
|
899
|
+
figsize: Tuple[int, int] = (16, 10),
|
|
900
|
+
save_path: Optional[str] = None,
|
|
901
|
+
dpi: int = 150,
|
|
902
|
+
) -> None:
|
|
903
|
+
"""Per-band statistics line chart for a single cube."""
|
|
904
|
+
self._check_loaded()
|
|
905
|
+
plt = _plt(); mpl = _mpl()
|
|
906
|
+
rec = self._records[cube_index]
|
|
907
|
+
if rec.is_corrupt or rec.band_means is None:
|
|
908
|
+
self._log("No band stats available.")
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
wl = (self.wavelengths[:rec.bands]
|
|
912
|
+
if self.wavelengths is not None else np.arange(rec.bands))
|
|
913
|
+
|
|
914
|
+
fig = plt.figure(figsize=figsize, facecolor="white")
|
|
915
|
+
fig.suptitle(
|
|
916
|
+
f"Per-band Statistics — "
|
|
917
|
+
f"{Path(rec.path).name if not rec.path.startswith('<') else rec.path}",
|
|
918
|
+
color="#1f2328", fontsize=13, fontweight="bold")
|
|
919
|
+
|
|
920
|
+
gs = mpl.gridspec.GridSpec(2, 3, figure=fig,
|
|
921
|
+
hspace=0.45, wspace=0.35)
|
|
922
|
+
|
|
923
|
+
def ax(*args):
|
|
924
|
+
a = fig.add_subplot(*args)
|
|
925
|
+
a.set_facecolor("#f6f8fa")
|
|
926
|
+
a.tick_params(colors="#57606a", labelsize=8)
|
|
927
|
+
for sp in a.spines.values():
|
|
928
|
+
sp.set_edgecolor("#d0d7de")
|
|
929
|
+
return a
|
|
930
|
+
|
|
931
|
+
def line_plot(axis, y, title, color, ylabel=""):
|
|
932
|
+
axis.plot(wl, y, color=color, lw=1.5)
|
|
933
|
+
axis.set_title(title, color="#1f2328", fontsize=9)
|
|
934
|
+
xlabel = ("Wavelength (nm)" if self.wavelengths is not None
|
|
935
|
+
else "Band index")
|
|
936
|
+
axis.set_xlabel(xlabel, color="#57606a", fontsize=8)
|
|
937
|
+
if ylabel:
|
|
938
|
+
axis.set_ylabel(ylabel, color="#57606a", fontsize=8)
|
|
939
|
+
|
|
940
|
+
line_plot(ax(gs[0, 0]), rec.band_means,
|
|
941
|
+
"Band Means", "#58a6ff", "Mean reflectance")
|
|
942
|
+
line_plot(ax(gs[0, 1]), rec.band_stds,
|
|
943
|
+
"Band Std Dev", "#3fb950", "Std dev")
|
|
944
|
+
line_plot(ax(gs[0, 2]), rec.band_snr,
|
|
945
|
+
"Band SNR", "#e3b341", "SNR")
|
|
946
|
+
line_plot(ax(gs[1, 0]), rec.band_noise_mad,
|
|
947
|
+
"Band Noise (MAD)", "#f78166", "Noise (MAD)")
|
|
948
|
+
line_plot(ax(gs[1, 1]), rec.band_saturation_frac,
|
|
949
|
+
"Band Saturation Fraction", "#d2a8ff", "Fraction")
|
|
950
|
+
line_plot(ax(gs[1, 2]),
|
|
951
|
+
np.abs(np.diff(rec.band_means, prepend=rec.band_means[0])),
|
|
952
|
+
"Spectral Gradient |Δmean|", "#79c0ff", "|Δ|")
|
|
953
|
+
|
|
954
|
+
self._finalise(fig, save_path, dpi)
|
|
955
|
+
|
|
956
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
957
|
+
# Plotting — spectral diversity heatmap
|
|
958
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
def plot_spectral_diversity(
|
|
961
|
+
self,
|
|
962
|
+
figsize: Tuple[int, int] = (10, 8),
|
|
963
|
+
save_path: Optional[str] = None,
|
|
964
|
+
dpi: int = 150,
|
|
965
|
+
) -> None:
|
|
966
|
+
"""Pairwise cosine-similarity heatmap between all cube mean spectra."""
|
|
967
|
+
self._check_loaded()
|
|
968
|
+
plt = _plt()
|
|
969
|
+
valid = [r for r in self._records if not r.is_corrupt
|
|
970
|
+
and r.band_means is not None]
|
|
971
|
+
dom_b = Counter(r.bands for r in valid).most_common(1)[0][0]
|
|
972
|
+
matching = [r for r in valid if r.bands == dom_b][:50]
|
|
973
|
+
|
|
974
|
+
if len(matching) < 2:
|
|
975
|
+
self._log("Need ≥ 2 cubes with matching band counts.")
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
spectra = np.stack([r.band_means for r in matching])
|
|
979
|
+
norms = np.linalg.norm(spectra, axis=1, keepdims=True) + 1e-9
|
|
980
|
+
sim = (spectra / norms) @ (spectra / norms).T
|
|
981
|
+
|
|
982
|
+
fig, axis = plt.subplots(figsize=figsize, facecolor="white")
|
|
983
|
+
axis.set_facecolor("#f6f8fa")
|
|
984
|
+
for sp in axis.spines.values():
|
|
985
|
+
sp.set_edgecolor("#d0d7de")
|
|
986
|
+
axis.tick_params(colors="#57606a", labelsize=7)
|
|
987
|
+
|
|
988
|
+
im = axis.imshow(sim, cmap="viridis", vmin=0, vmax=1, aspect="auto")
|
|
989
|
+
plt.colorbar(im, ax=axis, label="Cosine similarity"
|
|
990
|
+
).ax.tick_params(labelsize=7)
|
|
991
|
+
|
|
992
|
+
tick_labels = [(r.label or Path(r.path).stem)[:16] for r in matching]
|
|
993
|
+
if len(tick_labels) <= 30:
|
|
994
|
+
axis.set_xticks(range(len(tick_labels)))
|
|
995
|
+
axis.set_xticklabels(tick_labels, rotation=45,
|
|
996
|
+
ha="right", fontsize=6, color="#57606a")
|
|
997
|
+
axis.set_yticks(range(len(tick_labels)))
|
|
998
|
+
axis.set_yticklabels(tick_labels, fontsize=6, color="#57606a")
|
|
999
|
+
|
|
1000
|
+
axis.set_title(
|
|
1001
|
+
f"Spectral Diversity — cosine similarity ({len(matching)} cubes)",
|
|
1002
|
+
color="#1f2328", fontsize=11)
|
|
1003
|
+
fig.suptitle("HyperspectralEDA — Spectral Diversity",
|
|
1004
|
+
color="#1f2328", fontsize=13, fontweight="bold")
|
|
1005
|
+
self._finalise(fig, save_path, dpi)
|
|
1006
|
+
|
|
1007
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1008
|
+
# HTML Report
|
|
1009
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1010
|
+
|
|
1011
|
+
def report(self, output_path: str = "viseda_hyperspectral_report.html") -> str:
|
|
1012
|
+
"""Generate a self-contained HTML report."""
|
|
1013
|
+
self._check_loaded()
|
|
1014
|
+
s = self.summary()
|
|
1015
|
+
_generate_html_report(s, output_path)
|
|
1016
|
+
self._log(f"Report saved → {output_path}")
|
|
1017
|
+
return output_path
|
|
1018
|
+
|
|
1019
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1020
|
+
# Per-cube analysis
|
|
1021
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1022
|
+
|
|
1023
|
+
def _analyse_file(self, path: Path, label_from_parent: bool) -> CubeRecord:
|
|
1024
|
+
rec = CubeRecord()
|
|
1025
|
+
rec.path = str(path)
|
|
1026
|
+
rec.file_ext = path.suffix.lower()
|
|
1027
|
+
rec.file_size_kb = path.stat().st_size / 1024 if path.exists() else None
|
|
1028
|
+
rec.label = (path.parent.name if label_from_parent
|
|
1029
|
+
else self._label_map.get(str(path.resolve())))
|
|
1030
|
+
try:
|
|
1031
|
+
cube = self._read_cube(path)
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
self._log(f" ✗ {path.name}: {e}")
|
|
1034
|
+
rec.is_corrupt = True
|
|
1035
|
+
return rec
|
|
1036
|
+
self._fill_stats(rec, cube)
|
|
1037
|
+
return rec
|
|
1038
|
+
|
|
1039
|
+
def _fill_stats(self, rec: CubeRecord, cube: np.ndarray) -> None:
|
|
1040
|
+
if cube.ndim == 2:
|
|
1041
|
+
cube = cube[:, :, np.newaxis]
|
|
1042
|
+
cube = cube.astype(np.float32)
|
|
1043
|
+
|
|
1044
|
+
H, W, B = cube.shape
|
|
1045
|
+
rec.height = H; rec.width = W; rec.bands = B
|
|
1046
|
+
rec.dtype = str(cube.dtype)
|
|
1047
|
+
|
|
1048
|
+
# ── global scalars ────────────────────────────────────────────
|
|
1049
|
+
rec.global_mean = float(cube.mean())
|
|
1050
|
+
rec.global_std = float(cube.std())
|
|
1051
|
+
rec.global_min = float(cube.min())
|
|
1052
|
+
rec.global_max = float(cube.max())
|
|
1053
|
+
rec.dynamic_range = float(cube.max() - cube.min())
|
|
1054
|
+
|
|
1055
|
+
# ── per-band statistics ───────────────────────────────────────
|
|
1056
|
+
band_means = cube.mean(axis=(0, 1))
|
|
1057
|
+
band_stds = cube.std(axis=(0, 1))
|
|
1058
|
+
rec.band_means = band_means
|
|
1059
|
+
rec.band_stds = band_stds
|
|
1060
|
+
rec.band_mins = cube.min(axis=(0, 1))
|
|
1061
|
+
rec.band_maxs = cube.max(axis=(0, 1))
|
|
1062
|
+
|
|
1063
|
+
eps = 1e-9
|
|
1064
|
+
rec.band_snr = band_means / (band_stds + eps)
|
|
1065
|
+
rec.snr_mean = float(rec.band_snr.mean())
|
|
1066
|
+
rec.band_noise_mad = np.median(
|
|
1067
|
+
np.abs(cube - np.median(cube, axis=(0,1), keepdims=True)),
|
|
1068
|
+
axis=(0, 1))
|
|
1069
|
+
data_max = float(cube.max())
|
|
1070
|
+
rec.band_saturation_frac = (cube == data_max).mean(axis=(0, 1))
|
|
1071
|
+
|
|
1072
|
+
# ── spectral quality ─────────────────────────────────────────
|
|
1073
|
+
if B > 1:
|
|
1074
|
+
rec.spectral_smoothness = float(
|
|
1075
|
+
np.abs(np.diff(band_means)).mean()
|
|
1076
|
+
)
|
|
1077
|
+
else:
|
|
1078
|
+
rec.spectral_smoothness = 0.0
|
|
1079
|
+
glob_std = float(band_stds.std()) + eps
|
|
1080
|
+
rec.n_dropout_bands = int(
|
|
1081
|
+
(band_stds < self.dropout_threshold * glob_std).sum())
|
|
1082
|
+
|
|
1083
|
+
# inter-band correlation (mean off-diagonal) on pixel sample
|
|
1084
|
+
if B > 1:
|
|
1085
|
+
flat = cube.reshape(-1, B)
|
|
1086
|
+
if len(flat) > 5000:
|
|
1087
|
+
idx = np.random.default_rng(0).choice(len(flat), 5000, replace=False)
|
|
1088
|
+
flat = flat[idx]
|
|
1089
|
+
corr = np.corrcoef(flat.T)
|
|
1090
|
+
mask = ~np.eye(B, dtype=bool)
|
|
1091
|
+
rec.inter_band_corr = float(corr[mask].mean())
|
|
1092
|
+
else:
|
|
1093
|
+
rec.inter_band_corr = 0.0
|
|
1094
|
+
|
|
1095
|
+
# ── spatial maps ─────────────────────────────────────────────
|
|
1096
|
+
rec.spatial_mean_map = cube.mean(axis=2)
|
|
1097
|
+
rec.spatial_std_map = cube.std(axis=2)
|
|
1098
|
+
|
|
1099
|
+
# ── spectral indices ─────────────────────────────────────────
|
|
1100
|
+
for name in ("ndvi", "ndwi", "evi", "savi"):
|
|
1101
|
+
try:
|
|
1102
|
+
idx_map = self._compute_index(cube, name)
|
|
1103
|
+
setattr(rec, f"{name}_mean", float(idx_map.mean()))
|
|
1104
|
+
setattr(rec, f"{name}_std", float(idx_map.std()))
|
|
1105
|
+
except Exception:
|
|
1106
|
+
pass
|
|
1107
|
+
|
|
1108
|
+
# ── GLCM texture on first PC ──────────────────────────────────
|
|
1109
|
+
if self.compute_glcm and B > 1:
|
|
1110
|
+
self._fill_glcm(rec, cube)
|
|
1111
|
+
|
|
1112
|
+
# ── PCA variance profile ─────────────────────────────────────
|
|
1113
|
+
if self.compute_pca and B > 1:
|
|
1114
|
+
self._fill_pca(rec, cube)
|
|
1115
|
+
|
|
1116
|
+
def _fill_glcm(self, rec: CubeRecord, cube: np.ndarray) -> None:
|
|
1117
|
+
try:
|
|
1118
|
+
graycomatrix, graycoprops = _ski()
|
|
1119
|
+
from sklearn.decomposition import PCA as _PCA
|
|
1120
|
+
H, W, B = cube.shape
|
|
1121
|
+
flat = cube.reshape(-1, B)
|
|
1122
|
+
if len(flat) > 10_000:
|
|
1123
|
+
idx = np.random.default_rng(0).choice(len(flat), 10_000, replace=False)
|
|
1124
|
+
flat_s = flat[idx]
|
|
1125
|
+
else:
|
|
1126
|
+
flat_s = flat
|
|
1127
|
+
pc1 = _PCA(n_components=1, svd_solver="randomized").fit_transform(
|
|
1128
|
+
flat_s)
|
|
1129
|
+
# use the full image projection
|
|
1130
|
+
pc1_full = _PCA(n_components=1, svd_solver="randomized"
|
|
1131
|
+
).fit(flat_s).transform(flat).reshape(H, W)
|
|
1132
|
+
small = __import__("cv2").resize(pc1_full, (128, 128))
|
|
1133
|
+
# normalise to 0-63
|
|
1134
|
+
mn, mx = small.min(), small.max()
|
|
1135
|
+
quant = ((small - mn) / (mx - mn + 1e-9) * 63).astype(np.uint8)
|
|
1136
|
+
glcm = graycomatrix(quant, distances=[1],
|
|
1137
|
+
angles=[0, np.pi/4, np.pi/2, 3*np.pi/4],
|
|
1138
|
+
levels=64, symmetric=True, normed=True)
|
|
1139
|
+
rec.glcm_contrast = float(graycoprops(glcm, "contrast").mean())
|
|
1140
|
+
rec.glcm_dissimilarity = float(graycoprops(glcm, "dissimilarity").mean())
|
|
1141
|
+
rec.glcm_homogeneity = float(graycoprops(glcm, "homogeneity").mean())
|
|
1142
|
+
rec.glcm_energy = float(graycoprops(glcm, "energy").mean())
|
|
1143
|
+
rec.glcm_correlation = float(graycoprops(glcm, "correlation").mean())
|
|
1144
|
+
rec.glcm_asm = float(graycoprops(glcm, "ASM").mean())
|
|
1145
|
+
except Exception:
|
|
1146
|
+
pass
|
|
1147
|
+
|
|
1148
|
+
def _fill_pca(self, rec: CubeRecord, cube: np.ndarray) -> None:
|
|
1149
|
+
try:
|
|
1150
|
+
from sklearn.decomposition import PCA
|
|
1151
|
+
H, W, B = cube.shape
|
|
1152
|
+
flat = cube.reshape(-1, B)
|
|
1153
|
+
n = min(20, B, len(flat))
|
|
1154
|
+
pca = PCA(n_components=n, svd_solver="randomized")
|
|
1155
|
+
pca.fit(flat if len(flat) <= 10_000 else
|
|
1156
|
+
flat[np.random.default_rng(0).choice(len(flat), 10_000,
|
|
1157
|
+
replace=False)])
|
|
1158
|
+
rec.pca_variance_ratio = pca.explained_variance_ratio_.copy()
|
|
1159
|
+
except Exception:
|
|
1160
|
+
pass
|
|
1161
|
+
|
|
1162
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1163
|
+
# Spectral index computation
|
|
1164
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1165
|
+
|
|
1166
|
+
def _band_for_wl(self, target_nm: float, B: int) -> int:
|
|
1167
|
+
"""Return band index closest to *target_nm* wavelength."""
|
|
1168
|
+
if self.wavelengths is not None and len(self.wavelengths) >= B:
|
|
1169
|
+
return int(np.argmin(np.abs(self.wavelengths[:B] - target_nm)))
|
|
1170
|
+
# Fallback: assume VNIR 400-1000 nm linear mapping
|
|
1171
|
+
frac = (target_nm - 400) / 600
|
|
1172
|
+
return int(np.clip(frac * B, 0, B - 1))
|
|
1173
|
+
|
|
1174
|
+
def _compute_index(self, cube: np.ndarray, name: str) -> np.ndarray:
|
|
1175
|
+
B = cube.shape[2]
|
|
1176
|
+
nir = self._ndvi_nir or self._band_for_wl(850, B)
|
|
1177
|
+
red = self._ndvi_red or self._band_for_wl(670, B)
|
|
1178
|
+
green = self._ndwi_green or self._band_for_wl(560, B)
|
|
1179
|
+
blue = self._band_for_wl(490, B)
|
|
1180
|
+
nir = min(nir, B-1); red = min(red, B-1)
|
|
1181
|
+
green = min(green, B-1); blue = min(blue, B-1)
|
|
1182
|
+
|
|
1183
|
+
NIR = cube[:, :, nir].astype(np.float32)
|
|
1184
|
+
RED = cube[:, :, red].astype(np.float32)
|
|
1185
|
+
GRN = cube[:, :, green].astype(np.float32)
|
|
1186
|
+
BLU = cube[:, :, blue].astype(np.float32)
|
|
1187
|
+
|
|
1188
|
+
eps = 1e-9
|
|
1189
|
+
if name == "ndvi":
|
|
1190
|
+
return (NIR - RED) / (NIR + RED + eps)
|
|
1191
|
+
if name == "ndwi":
|
|
1192
|
+
return (GRN - NIR) / (GRN + NIR + eps)
|
|
1193
|
+
if name == "evi":
|
|
1194
|
+
return 2.5 * (NIR - RED) / (NIR + 6*RED - 7.5*BLU + 1 + eps)
|
|
1195
|
+
if name == "savi":
|
|
1196
|
+
L = 0.5
|
|
1197
|
+
return (1 + L) * (NIR - RED) / (NIR + RED + L + eps)
|
|
1198
|
+
raise ValueError(f"Unknown index: {name}")
|
|
1199
|
+
|
|
1200
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1201
|
+
# File reading
|
|
1202
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1203
|
+
|
|
1204
|
+
def _read_cube(self, path: Path) -> np.ndarray:
|
|
1205
|
+
suffix = path.suffix.lower()
|
|
1206
|
+
if suffix == ".npy":
|
|
1207
|
+
return np.load(str(path)).astype(np.float32)
|
|
1208
|
+
if suffix == ".npz":
|
|
1209
|
+
data = np.load(str(path))
|
|
1210
|
+
key = list(data.keys())[0]
|
|
1211
|
+
return data[key].astype(np.float32)
|
|
1212
|
+
if suffix == ".mat":
|
|
1213
|
+
import scipy.io
|
|
1214
|
+
mat = scipy.io.loadmat(str(path))
|
|
1215
|
+
keys = [k for k in mat if not k.startswith("_")]
|
|
1216
|
+
if not keys:
|
|
1217
|
+
raise ValueError("No data arrays found in .mat file.")
|
|
1218
|
+
# prefer the key with the largest array
|
|
1219
|
+
key = max(keys, key=lambda k: np.prod(mat[k].shape)
|
|
1220
|
+
if hasattr(mat[k], "shape") else 0)
|
|
1221
|
+
return np.array(mat[key]).astype(np.float32)
|
|
1222
|
+
if suffix in (".hdr", ".bil", ".bip", ".bsq", ".envi"):
|
|
1223
|
+
try:
|
|
1224
|
+
import spectral
|
|
1225
|
+
img = spectral.open_image(str(path))
|
|
1226
|
+
return img.load().astype(np.float32)
|
|
1227
|
+
except ImportError:
|
|
1228
|
+
raise ImportError("pip install spectral")
|
|
1229
|
+
if suffix in (".tif", ".tiff"):
|
|
1230
|
+
try:
|
|
1231
|
+
import rasterio
|
|
1232
|
+
with rasterio.open(str(path)) as src:
|
|
1233
|
+
arr = src.read() # (B, H, W)
|
|
1234
|
+
return arr.transpose(1, 2, 0).astype(np.float32)
|
|
1235
|
+
except ImportError:
|
|
1236
|
+
raise ImportError("pip install rasterio")
|
|
1237
|
+
raise ValueError(f"Unsupported format: {suffix}")
|
|
1238
|
+
|
|
1239
|
+
def _load_cube_array(self, rec: CubeRecord) -> np.ndarray:
|
|
1240
|
+
"""Load the raw cube array for a record (re-reads from disk)."""
|
|
1241
|
+
if rec.path.startswith("<array"):
|
|
1242
|
+
raise ValueError(
|
|
1243
|
+
"Cannot reload in-memory arrays. "
|
|
1244
|
+
"Store arrays externally and use load() with file paths.")
|
|
1245
|
+
return self._read_cube(Path(rec.path))
|
|
1246
|
+
|
|
1247
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1248
|
+
# Plot helpers — single cube
|
|
1249
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1250
|
+
|
|
1251
|
+
def _plot_cube_info(self, ax, rec, cube):
|
|
1252
|
+
ax.axis("off")
|
|
1253
|
+
H, W, B = cube.shape
|
|
1254
|
+
wl_range = (f"{self.wavelengths[0]:.0f}–{self.wavelengths[B-1]:.0f} nm"
|
|
1255
|
+
if self.wavelengths is not None else "unknown")
|
|
1256
|
+
lines = [
|
|
1257
|
+
f"Shape: {H} × {W} × {B} bands",
|
|
1258
|
+
f"Wavelengths: {wl_range}",
|
|
1259
|
+
f"File size: {rec.file_size_kb:.1f} KB"
|
|
1260
|
+
if rec.file_size_kb else "File size: N/A",
|
|
1261
|
+
f"Global mean: {rec.global_mean:.4f}",
|
|
1262
|
+
f"Global std: {rec.global_std:.4f}",
|
|
1263
|
+
f"Dynamic range: {rec.dynamic_range:.4f}",
|
|
1264
|
+
f"Mean SNR: {rec.snr_mean:.2f}",
|
|
1265
|
+
f"Dropout bands: {rec.n_dropout_bands}",
|
|
1266
|
+
f"Spectral smooth:{rec.spectral_smoothness:.5f}",
|
|
1267
|
+
f"Inter-band corr:{rec.inter_band_corr:.4f}",
|
|
1268
|
+
f"Label: {rec.label or 'N/A'}",
|
|
1269
|
+
]
|
|
1270
|
+
ax.text(0.04, 0.97, "\n".join(lines),
|
|
1271
|
+
transform=ax.transAxes, va="top", ha="left",
|
|
1272
|
+
fontsize=8.5, color="#1f2328", fontfamily="monospace",
|
|
1273
|
+
bbox=dict(boxstyle="round,pad=0.5",
|
|
1274
|
+
facecolor="#eaeef2", edgecolor="#d0d7de"))
|
|
1275
|
+
ax.set_title("Cube Overview", color="#1f2328", fontsize=11)
|
|
1276
|
+
|
|
1277
|
+
def _plot_false_colour(self, ax, cube, rgb_bands, title="False Colour"):
|
|
1278
|
+
B = cube.shape[2]
|
|
1279
|
+
b = [min(x, B-1) for x in rgb_bands]
|
|
1280
|
+
fc = cube[:, :, b].astype(np.float32)
|
|
1281
|
+
fc = (fc - fc.min()) / (fc.max() - fc.min() + 1e-9)
|
|
1282
|
+
ax.imshow(np.clip(fc, 0, 1))
|
|
1283
|
+
ax.set_title(title, color="#1f2328", fontsize=9)
|
|
1284
|
+
ax.axis("off")
|
|
1285
|
+
|
|
1286
|
+
def _plot_spatial_mean(self, ax, rec):
|
|
1287
|
+
if rec.spatial_mean_map is None:
|
|
1288
|
+
ax.set_title("Spatial Mean", color="#1f2328", fontsize=9)
|
|
1289
|
+
return
|
|
1290
|
+
im = ax.imshow(rec.spatial_mean_map, cmap="gray")
|
|
1291
|
+
_plt().colorbar(im, ax=ax, fraction=0.046, pad=0.04
|
|
1292
|
+
).ax.tick_params(labelsize=6)
|
|
1293
|
+
ax.set_title("Spatial Mean Map (avg across bands)",
|
|
1294
|
+
color="#1f2328", fontsize=9)
|
|
1295
|
+
ax.axis("off")
|
|
1296
|
+
|
|
1297
|
+
def _plot_mean_spectrum_single(self, ax, rec, wl):
|
|
1298
|
+
if rec.band_means is None:
|
|
1299
|
+
return
|
|
1300
|
+
B = len(rec.band_means)
|
|
1301
|
+
x = wl[:B]
|
|
1302
|
+
ax.plot(x, rec.band_means, color="#58a6ff", lw=1.8, label="Mean")
|
|
1303
|
+
ax.fill_between(x,
|
|
1304
|
+
rec.band_means - rec.band_stds,
|
|
1305
|
+
rec.band_means + rec.band_stds,
|
|
1306
|
+
alpha=0.2, color="#58a6ff", label="±1σ")
|
|
1307
|
+
ax.set_title("Mean Spectral Signature ± 1σ",
|
|
1308
|
+
color="#1f2328", fontsize=9)
|
|
1309
|
+
xlabel = "Wavelength (nm)" if self.wavelengths is not None else "Band index"
|
|
1310
|
+
ax.set_xlabel(xlabel, color="#57606a", fontsize=8)
|
|
1311
|
+
ax.set_ylabel("Reflectance", color="#57606a", fontsize=8)
|
|
1312
|
+
ax.legend(fontsize=7, labelcolor="#1f2328",
|
|
1313
|
+
facecolor="white", edgecolor="#d0d7de")
|
|
1314
|
+
|
|
1315
|
+
def _plot_band_snr(self, ax, rec, wl):
|
|
1316
|
+
if rec.band_snr is None:
|
|
1317
|
+
return
|
|
1318
|
+
B = len(rec.band_snr)
|
|
1319
|
+
ax.plot(wl[:B], rec.band_snr, color="#e3b341", lw=1.5)
|
|
1320
|
+
ax.axhline(rec.snr_mean, color="#f78166", lw=1,
|
|
1321
|
+
linestyle="--", alpha=0.8,
|
|
1322
|
+
label=f"Mean SNR = {rec.snr_mean:.1f}")
|
|
1323
|
+
ax.set_title("SNR per Band", color="#1f2328", fontsize=9)
|
|
1324
|
+
ax.set_xlabel("Band / Wavelength", color="#57606a", fontsize=8)
|
|
1325
|
+
ax.set_ylabel("SNR", color="#57606a", fontsize=8)
|
|
1326
|
+
ax.legend(fontsize=7, labelcolor="#1f2328",
|
|
1327
|
+
facecolor="white", edgecolor="#d0d7de")
|
|
1328
|
+
|
|
1329
|
+
def _plot_band_stats_lines(self, ax, rec, wl):
|
|
1330
|
+
if rec.band_means is None:
|
|
1331
|
+
return
|
|
1332
|
+
B = len(rec.band_means)
|
|
1333
|
+
x = wl[:B]
|
|
1334
|
+
ax.plot(x, rec.band_means, color="#58a6ff", lw=1.5, label="Mean")
|
|
1335
|
+
ax.plot(x, rec.band_maxs, color="#f78166", lw=1,
|
|
1336
|
+
alpha=0.7, label="Max")
|
|
1337
|
+
ax.plot(x, rec.band_mins, color="#3fb950", lw=1,
|
|
1338
|
+
alpha=0.7, label="Min")
|
|
1339
|
+
ax.fill_between(x, rec.band_mins, rec.band_maxs,
|
|
1340
|
+
alpha=0.07, color="#58a6ff")
|
|
1341
|
+
ax.set_title("Per-band Min / Mean / Max",
|
|
1342
|
+
color="#1f2328", fontsize=9)
|
|
1343
|
+
xlabel = "Wavelength (nm)" if self.wavelengths is not None else "Band"
|
|
1344
|
+
ax.set_xlabel(xlabel, color="#57606a", fontsize=8)
|
|
1345
|
+
ax.legend(fontsize=7, labelcolor="#1f2328",
|
|
1346
|
+
facecolor="white", edgecolor="#d0d7de")
|
|
1347
|
+
|
|
1348
|
+
def _plot_index_map_single(self, ax, cube, name):
|
|
1349
|
+
try:
|
|
1350
|
+
idx_map = self._compute_index(cube, name)
|
|
1351
|
+
cmap = {"ndvi": "RdYlGn", "ndwi": "RdBu",
|
|
1352
|
+
"evi": "YlGn", "savi": "Greens"}.get(name, "viridis")
|
|
1353
|
+
im = ax.imshow(idx_map, cmap=cmap, vmin=-1, vmax=1)
|
|
1354
|
+
_plt().colorbar(im, ax=ax, fraction=0.046, pad=0.04
|
|
1355
|
+
).ax.tick_params(labelsize=6)
|
|
1356
|
+
ax.set_title(
|
|
1357
|
+
f"{name.upper()} (μ={idx_map.mean():.3f})",
|
|
1358
|
+
color="#1f2328", fontsize=9)
|
|
1359
|
+
except Exception as e:
|
|
1360
|
+
ax.text(0.5, 0.5, f"{name.upper()}\nN/A\n{e}",
|
|
1361
|
+
ha="center", va="center", color="#57606a",
|
|
1362
|
+
transform=ax.transAxes, fontsize=8)
|
|
1363
|
+
ax.set_title(name.upper(), color="#1f2328", fontsize=9)
|
|
1364
|
+
ax.axis("off")
|
|
1365
|
+
|
|
1366
|
+
def _plot_pca_variance_single(self, ax, rec):
|
|
1367
|
+
if rec.pca_variance_ratio is None:
|
|
1368
|
+
ax.text(0.5, 0.5, "PCA not computed",
|
|
1369
|
+
ha="center", va="center", color="#57606a",
|
|
1370
|
+
transform=ax.transAxes)
|
|
1371
|
+
ax.set_title("PCA Variance", color="#1f2328", fontsize=9)
|
|
1372
|
+
return
|
|
1373
|
+
v = rec.pca_variance_ratio
|
|
1374
|
+
cv = np.cumsum(v) * 100
|
|
1375
|
+
x = np.arange(1, len(v)+1)
|
|
1376
|
+
ax.bar(x, v*100, color="#d2a8ff", alpha=0.8)
|
|
1377
|
+
ax.plot(x, cv, color="#1f2328", lw=1.5, marker=".", markersize=4)
|
|
1378
|
+
ax.axhline(95, color="#f78166", lw=1, linestyle="--",
|
|
1379
|
+
alpha=0.7, label="95%")
|
|
1380
|
+
ax.set_title("PCA Variance Explained",
|
|
1381
|
+
color="#1f2328", fontsize=9)
|
|
1382
|
+
ax.set_xlabel("Component", color="#57606a", fontsize=8)
|
|
1383
|
+
ax.set_ylabel("% variance", color="#57606a", fontsize=8)
|
|
1384
|
+
ax.legend(fontsize=7, labelcolor="#1f2328",
|
|
1385
|
+
facecolor="white", edgecolor="#d0d7de")
|
|
1386
|
+
|
|
1387
|
+
def _plot_glcm_radar(self, ax, records):
|
|
1388
|
+
feats = ["glcm_contrast","glcm_homogeneity",
|
|
1389
|
+
"glcm_energy","glcm_correlation","glcm_asm"]
|
|
1390
|
+
labels = ["Contrast","Homogeneity","Energy","Correlation","ASM"]
|
|
1391
|
+
vals = []
|
|
1392
|
+
for f in feats:
|
|
1393
|
+
a = [getattr(r, f) for r in records if getattr(r, f) is not None]
|
|
1394
|
+
vals.append(float(np.mean(a)) if a else 0.0)
|
|
1395
|
+
|
|
1396
|
+
fig = ax.get_figure()
|
|
1397
|
+
pos = ax.get_position()
|
|
1398
|
+
ax.remove()
|
|
1399
|
+
|
|
1400
|
+
if all(v == 0 for v in vals):
|
|
1401
|
+
ax2 = fig.add_axes(pos)
|
|
1402
|
+
ax2.set_facecolor("#f6f8fa")
|
|
1403
|
+
ax2.text(0.5, 0.5, "GLCM not computed\n(pip install scikit-image)",
|
|
1404
|
+
ha="center", va="center", color="#57606a",
|
|
1405
|
+
transform=ax2.transAxes, fontsize=9)
|
|
1406
|
+
ax2.set_title("Texture (GLCM)", color="#1f2328", fontsize=9)
|
|
1407
|
+
ax2.set_xticks([]); ax2.set_yticks([])
|
|
1408
|
+
return
|
|
1409
|
+
|
|
1410
|
+
ax_p = fig.add_axes(pos, projection="polar")
|
|
1411
|
+
ax_p.set_facecolor("#f6f8fa")
|
|
1412
|
+
mx = max(vals) or 1.0
|
|
1413
|
+
nv = [v/mx for v in vals] + [vals[0]/mx]
|
|
1414
|
+
angles = np.linspace(0, 2*np.pi, len(labels), endpoint=False).tolist()
|
|
1415
|
+
angles += angles[:1]
|
|
1416
|
+
ax_p.set_theta_offset(np.pi/2)
|
|
1417
|
+
ax_p.set_theta_direction(-1)
|
|
1418
|
+
ax_p.plot(angles, nv, color="#58a6ff", lw=2)
|
|
1419
|
+
ax_p.fill(angles, nv, color="#58a6ff", alpha=0.25)
|
|
1420
|
+
ax_p.set_xticks(angles[:-1])
|
|
1421
|
+
ax_p.set_xticklabels(labels, color="#1f2328", fontsize=7)
|
|
1422
|
+
ax_p.set_yticks([0.25, 0.5, 0.75, 1.0])
|
|
1423
|
+
ax_p.set_yticklabels(["25%","50%","75%","100%"],
|
|
1424
|
+
color="#57606a", fontsize=6)
|
|
1425
|
+
ax_p.spines["polar"].set_edgecolor("#d0d7de")
|
|
1426
|
+
ax_p.tick_params(colors="#57606a")
|
|
1427
|
+
ax_p.set_title("Texture Features (GLCM, normalised)",
|
|
1428
|
+
color="#1f2328", fontsize=9, pad=12)
|
|
1429
|
+
|
|
1430
|
+
def _plot_spectral_quality_bars(self, ax, records):
|
|
1431
|
+
metrics = {
|
|
1432
|
+
"Mean SNR": [r.snr_mean for r in records
|
|
1433
|
+
if r.snr_mean is not None],
|
|
1434
|
+
"Dropout\nbands": [r.n_dropout_bands for r in records
|
|
1435
|
+
if r.n_dropout_bands is not None],
|
|
1436
|
+
"Smooth-\nness": [r.spectral_smoothness for r in records
|
|
1437
|
+
if r.spectral_smoothness is not None],
|
|
1438
|
+
"Inter-band\ncorr": [r.inter_band_corr for r in records
|
|
1439
|
+
if r.inter_band_corr is not None],
|
|
1440
|
+
}
|
|
1441
|
+
colors = ["#e3b341","#f78166","#3fb950","#58a6ff"]
|
|
1442
|
+
labels, means, stds = [], [], []
|
|
1443
|
+
for (lbl, vals), color in zip(metrics.items(), colors):
|
|
1444
|
+
if vals:
|
|
1445
|
+
labels.append(lbl)
|
|
1446
|
+
means.append(float(np.mean(vals)))
|
|
1447
|
+
stds.append(float(np.std(vals)))
|
|
1448
|
+
|
|
1449
|
+
if not labels:
|
|
1450
|
+
ax.set_title("Spectral Quality", color="#1f2328", fontsize=9)
|
|
1451
|
+
return
|
|
1452
|
+
y = np.arange(len(labels))
|
|
1453
|
+
ax.barh(y, means, xerr=stds, color=colors[:len(labels)],
|
|
1454
|
+
alpha=0.8, edgecolor="none",
|
|
1455
|
+
error_kw=dict(ecolor="#57606a", elinewidth=1))
|
|
1456
|
+
ax.set_yticks(y)
|
|
1457
|
+
ax.set_yticklabels(labels, fontsize=8)
|
|
1458
|
+
ax.set_title("Spectral Quality Metrics",
|
|
1459
|
+
color="#1f2328", fontsize=9)
|
|
1460
|
+
|
|
1461
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1462
|
+
# Plot helpers — dataset level
|
|
1463
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1464
|
+
|
|
1465
|
+
def _plot_dataset_info(self, ax, valid, s):
|
|
1466
|
+
ax.axis("off")
|
|
1467
|
+
inv = s["inventory"]
|
|
1468
|
+
sq = s["spectral_quality"]
|
|
1469
|
+
lines = [
|
|
1470
|
+
f"Total cubes: {inv['total_cubes']:,}",
|
|
1471
|
+
f"Valid: {inv['valid_cubes']:,}",
|
|
1472
|
+
f"Corrupt: {inv['corrupt_cubes']:,}",
|
|
1473
|
+
f"Unique labels: "
|
|
1474
|
+
f"{len(inv['label_distribution']) if inv['label_distribution'] else 'N/A'}",
|
|
1475
|
+
f"Band counts: {inv['band_distribution']}",
|
|
1476
|
+
f"Formats: {inv['format_distribution']}",
|
|
1477
|
+
f"Mean SNR: {sq['snr_mean'].get('mean','N/A')}",
|
|
1478
|
+
f"Median H × W: "
|
|
1479
|
+
f"{s['spatial']['height'].get('median','?'):.0f} × "
|
|
1480
|
+
f"{s['spatial']['width'].get('median','?'):.0f}",
|
|
1481
|
+
]
|
|
1482
|
+
ax.text(0.04, 0.97, "\n".join(lines),
|
|
1483
|
+
transform=ax.transAxes, va="top", ha="left",
|
|
1484
|
+
fontsize=9, color="#1f2328", fontfamily="monospace",
|
|
1485
|
+
bbox=dict(boxstyle="round,pad=0.5",
|
|
1486
|
+
facecolor="#eaeef2", edgecolor="#d0d7de"))
|
|
1487
|
+
ax.set_title("Dataset Overview", color="#1f2328", fontsize=11)
|
|
1488
|
+
|
|
1489
|
+
def _plot_label_dist(self, ax, valid):
|
|
1490
|
+
labels = [r.label for r in valid if r.label]
|
|
1491
|
+
if not labels:
|
|
1492
|
+
ax.text(0.5, 0.5, "No labels provided\n(use label_from_parent=True)",
|
|
1493
|
+
ha="center", va="center", color="#57606a",
|
|
1494
|
+
transform=ax.transAxes, fontsize=9)
|
|
1495
|
+
ax.set_title("Label Distribution", color="#1f2328", fontsize=11)
|
|
1496
|
+
return
|
|
1497
|
+
cnt = Counter(labels).most_common(25)
|
|
1498
|
+
names, counts = zip(*cnt)
|
|
1499
|
+
y = np.arange(len(names))
|
|
1500
|
+
ax.barh(y, counts, color="#58a6ff", alpha=0.85)
|
|
1501
|
+
ax.set_yticks(y)
|
|
1502
|
+
ax.set_yticklabels(names, fontsize=7)
|
|
1503
|
+
ax.set_title("Label Distribution", color="#1f2328", fontsize=11)
|
|
1504
|
+
ax.set_xlabel("Count", color="#57606a", fontsize=8)
|
|
1505
|
+
if len(counts) > 1:
|
|
1506
|
+
ratio = max(counts) / min(counts)
|
|
1507
|
+
ax.text(0.97, 0.02, f"imbalance: {ratio:.1f}×",
|
|
1508
|
+
transform=ax.transAxes, ha="right", va="bottom",
|
|
1509
|
+
fontsize=7, color="#e3b341")
|
|
1510
|
+
|
|
1511
|
+
def _plot_hist(self, ax, data, title: str, color: str = "#58a6ff") -> None:
|
|
1512
|
+
"""Histogram with explicit metric axes."""
|
|
1513
|
+
vals = np.asarray([v for v in data if v is not None and np.isfinite(v)], dtype=float)
|
|
1514
|
+
if vals.size == 0:
|
|
1515
|
+
ax.set_title(title)
|
|
1516
|
+
ax.set_xlabel(title)
|
|
1517
|
+
ax.set_ylabel("Number of cubes")
|
|
1518
|
+
ax.text(0.5, 0.5, "No data", ha="center", va="center")
|
|
1519
|
+
return
|
|
1520
|
+
ax.hist(vals, bins=min(30, max(5, vals.size)), color=color, alpha=0.85, edgecolor="white")
|
|
1521
|
+
ax.axvline(vals.mean(), color="#1f2328", linestyle="--", linewidth=1,
|
|
1522
|
+
label=f"Mean = {vals.mean():.2f}")
|
|
1523
|
+
ax.set_title(title)
|
|
1524
|
+
ax.set_xlabel(title)
|
|
1525
|
+
ax.set_ylabel("Number of cubes")
|
|
1526
|
+
ax.legend(fontsize=7)
|
|
1527
|
+
|
|
1528
|
+
def _plot_cross_spectrum(self, ax, s):
|
|
1529
|
+
ss = s["spectral_stats"]
|
|
1530
|
+
mean_spec = ss.get("cross_cube_mean_spectrum")
|
|
1531
|
+
std_spec = ss.get("cross_cube_std_spectrum")
|
|
1532
|
+
n = ss.get("n_matching_cubes", 0)
|
|
1533
|
+
dom_b = ss.get("dominant_band_count", 0)
|
|
1534
|
+
|
|
1535
|
+
if not mean_spec:
|
|
1536
|
+
ax.text(0.5, 0.5, "No matching-band cubes",
|
|
1537
|
+
ha="center", va="center", color="#57606a",
|
|
1538
|
+
transform=ax.transAxes)
|
|
1539
|
+
ax.set_title("Cross-Cube Mean Spectrum", color="#1f2328", fontsize=9)
|
|
1540
|
+
return
|
|
1541
|
+
|
|
1542
|
+
mean_spec = np.array(mean_spec)
|
|
1543
|
+
std_spec = np.array(std_spec) if std_spec else np.zeros_like(mean_spec)
|
|
1544
|
+
wl = (self.wavelengths[:dom_b]
|
|
1545
|
+
if self.wavelengths is not None else np.arange(dom_b))
|
|
1546
|
+
|
|
1547
|
+
ax.plot(wl, mean_spec, color="#58a6ff", lw=1.8, label="Dataset mean")
|
|
1548
|
+
ax.fill_between(wl, mean_spec-std_spec, mean_spec+std_spec,
|
|
1549
|
+
alpha=0.2, color="#58a6ff", label="±1σ across cubes")
|
|
1550
|
+
ax.set_title(f"Cross-Cube Mean Spectrum (n={n} cubes, {dom_b} bands)",
|
|
1551
|
+
color="#1f2328", fontsize=9)
|
|
1552
|
+
xlabel = "Wavelength (nm)" if self.wavelengths is not None else "Band index"
|
|
1553
|
+
ax.set_xlabel(xlabel, color="#57606a", fontsize=8)
|
|
1554
|
+
ax.set_ylabel("Mean Reflectance", color="#57606a", fontsize=8)
|
|
1555
|
+
ax.legend(fontsize=7, labelcolor="#1f2328",
|
|
1556
|
+
facecolor="white", edgecolor="#d0d7de")
|
|
1557
|
+
|
|
1558
|
+
def _plot_band_count_dist(self, ax, valid):
|
|
1559
|
+
cnt = Counter(r.bands for r in valid)
|
|
1560
|
+
names, counts = zip(*sorted(cnt.items()))
|
|
1561
|
+
ax.bar([str(n) for n in names], counts,
|
|
1562
|
+
color="#d2a8ff", alpha=0.85, edgecolor="none")
|
|
1563
|
+
ax.set_title("Band Count Distribution", color="#1f2328", fontsize=9)
|
|
1564
|
+
ax.set_xlabel("Bands", color="#57606a", fontsize=8)
|
|
1565
|
+
ax.set_ylabel("Cubes", color="#57606a", fontsize=8)
|
|
1566
|
+
|
|
1567
|
+
def _plot_spectral_diversity(self, ax, valid):
|
|
1568
|
+
dom_b = Counter(r.bands for r in valid).most_common(1)[0][0]
|
|
1569
|
+
m = [r for r in valid if r.bands == dom_b
|
|
1570
|
+
and r.band_means is not None][:50]
|
|
1571
|
+
|
|
1572
|
+
if len(m) < 2:
|
|
1573
|
+
ax.text(0.5, 0.5, "Need ≥2 cubes\nwith same bands",
|
|
1574
|
+
ha="center", va="center", color="#57606a",
|
|
1575
|
+
transform=ax.transAxes, fontsize=9)
|
|
1576
|
+
ax.set_title("Spectral Diversity", color="#1f2328", fontsize=9)
|
|
1577
|
+
return
|
|
1578
|
+
|
|
1579
|
+
spectra = np.stack([r.band_means for r in m])
|
|
1580
|
+
norms = np.linalg.norm(spectra, axis=1, keepdims=True) + 1e-9
|
|
1581
|
+
sim = (spectra / norms) @ (spectra / norms).T
|
|
1582
|
+
|
|
1583
|
+
im = ax.imshow(sim, cmap="viridis", vmin=0, vmax=1, aspect="auto")
|
|
1584
|
+
_plt().colorbar(im, ax=ax, fraction=0.046, pad=0.04,
|
|
1585
|
+
label="Cosine sim").ax.tick_params(labelsize=6)
|
|
1586
|
+
tick_labels = [(r.label or Path(r.path).stem)[:12] for r in m]
|
|
1587
|
+
if len(tick_labels) <= 20:
|
|
1588
|
+
ax.set_xticks(range(len(tick_labels)))
|
|
1589
|
+
ax.set_xticklabels(tick_labels, rotation=45, ha="right",
|
|
1590
|
+
fontsize=6, color="#57606a")
|
|
1591
|
+
ax.set_yticks(range(len(tick_labels)))
|
|
1592
|
+
ax.set_yticklabels(tick_labels, fontsize=6, color="#57606a")
|
|
1593
|
+
ax.set_title(f"Spectral Diversity ({len(m)} cubes, {dom_b} bands)",
|
|
1594
|
+
color="#1f2328", fontsize=9)
|
|
1595
|
+
|
|
1596
|
+
def _plot_pca_variance_dataset(self, ax, valid):
|
|
1597
|
+
pca_vars = [r.pca_variance_ratio for r in valid
|
|
1598
|
+
if r.pca_variance_ratio is not None]
|
|
1599
|
+
if not pca_vars:
|
|
1600
|
+
ax.text(0.5, 0.5, "PCA not computed",
|
|
1601
|
+
ha="center", va="center", color="#57606a",
|
|
1602
|
+
transform=ax.transAxes)
|
|
1603
|
+
ax.set_title("PCA (dataset mean)", color="#1f2328", fontsize=9)
|
|
1604
|
+
return
|
|
1605
|
+
min_len = min(len(v) for v in pca_vars)
|
|
1606
|
+
stack = np.stack([v[:min_len] for v in pca_vars])
|
|
1607
|
+
mean_v = stack.mean(axis=0)
|
|
1608
|
+
std_v = stack.std(axis=0)
|
|
1609
|
+
cumvar = np.cumsum(mean_v) * 100
|
|
1610
|
+
x = np.arange(1, min_len+1)
|
|
1611
|
+
ax.bar(x, mean_v*100, yerr=std_v*100, color="#d2a8ff",
|
|
1612
|
+
alpha=0.8, error_kw=dict(ecolor="#57606a", elinewidth=0.8))
|
|
1613
|
+
ax.plot(x, cumvar, color="#1f2328", lw=1.5,
|
|
1614
|
+
marker=".", markersize=3)
|
|
1615
|
+
ax.axhline(95, color="#f78166", lw=1, linestyle="--",
|
|
1616
|
+
alpha=0.7, label="95%")
|
|
1617
|
+
ax.set_title("PCA Variance (dataset mean ± std)",
|
|
1618
|
+
color="#1f2328", fontsize=9)
|
|
1619
|
+
ax.set_xlabel("Component", color="#57606a", fontsize=8)
|
|
1620
|
+
ax.set_ylabel("% variance", color="#57606a", fontsize=8)
|
|
1621
|
+
ax.legend(fontsize=7, labelcolor="#1f2328",
|
|
1622
|
+
facecolor="white", edgecolor="#d0d7de")
|
|
1623
|
+
|
|
1624
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1625
|
+
# Helpers
|
|
1626
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1627
|
+
|
|
1628
|
+
def _resolve_paths(self, source, recursive) -> List[Path]:
|
|
1629
|
+
if isinstance(source, (list, tuple)):
|
|
1630
|
+
return [Path(p) for p in source]
|
|
1631
|
+
source = Path(source)
|
|
1632
|
+
if source.is_file():
|
|
1633
|
+
return [source]
|
|
1634
|
+
pattern = "**/*" if recursive else "*"
|
|
1635
|
+
return sorted(
|
|
1636
|
+
p for p in source.glob(pattern)
|
|
1637
|
+
if p.is_file() and p.suffix.lower() in self.SUPPORTED_EXTS
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
def _log(self, msg: str) -> None:
|
|
1641
|
+
if self.verbose:
|
|
1642
|
+
print(f"[viseda] {msg}")
|
|
1643
|
+
|
|
1644
|
+
def _check_loaded(self):
|
|
1645
|
+
if not self._loaded:
|
|
1646
|
+
raise RuntimeError("Call .load() or .load_arrays() first.")
|
|
1647
|
+
|
|
1648
|
+
@staticmethod
|
|
1649
|
+
def _finalise(fig, save_path, dpi):
|
|
1650
|
+
plt = _plt()
|
|
1651
|
+
if save_path:
|
|
1652
|
+
fig.savefig(save_path, dpi=dpi, bbox_inches="tight",
|
|
1653
|
+
facecolor=fig.get_facecolor())
|
|
1654
|
+
else:
|
|
1655
|
+
plt.show()
|
|
1656
|
+
plt.close(fig)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1660
|
+
# Utilities
|
|
1661
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1662
|
+
|
|
1663
|
+
def _stat_dict(arr) -> Dict[str, float]:
|
|
1664
|
+
arr = np.asarray([x for x in np.asarray(arr).ravel()
|
|
1665
|
+
if x is not None and np.isfinite(x)])
|
|
1666
|
+
if len(arr) == 0:
|
|
1667
|
+
return {}
|
|
1668
|
+
return {
|
|
1669
|
+
"min": round(float(np.min(arr)), 4),
|
|
1670
|
+
"max": round(float(np.max(arr)), 4),
|
|
1671
|
+
"mean": round(float(np.mean(arr)), 4),
|
|
1672
|
+
"median": round(float(np.median(arr)), 4),
|
|
1673
|
+
"std": round(float(np.std(arr)), 4),
|
|
1674
|
+
"p25": round(float(np.percentile(arr, 25)), 4),
|
|
1675
|
+
"p75": round(float(np.percentile(arr, 75)), 4),
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1680
|
+
# HTML Report
|
|
1681
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1682
|
+
|
|
1683
|
+
def _generate_html_report(summary: Dict[str, Any], output_path: str) -> None:
|
|
1684
|
+
inv = summary.get("inventory", {})
|
|
1685
|
+
sp = summary.get("spatial", {})
|
|
1686
|
+
ss = summary.get("spectral_stats", {})
|
|
1687
|
+
sq = summary.get("spectral_quality", {})
|
|
1688
|
+
si = summary.get("spectral_indices", {})
|
|
1689
|
+
tx = summary.get("texture", {})
|
|
1690
|
+
pc = summary.get("pca", {})
|
|
1691
|
+
lb = summary.get("labels", {})
|
|
1692
|
+
|
|
1693
|
+
def card(title, stats):
|
|
1694
|
+
if not stats:
|
|
1695
|
+
return ""
|
|
1696
|
+
rows = "".join(
|
|
1697
|
+
f'<div class="stat"><span>{k}</span>'
|
|
1698
|
+
f'<span class="val">{_fmt(v)}</span></div>'
|
|
1699
|
+
for k, v in stats.items() if not isinstance(v, (list, dict))
|
|
1700
|
+
)
|
|
1701
|
+
return f'<div class="card"><h3>{title}</h3>{rows}</div>'
|
|
1702
|
+
|
|
1703
|
+
def badge(text, cls="blue"):
|
|
1704
|
+
return f'<span class="badge badge-{cls}">{text}</span>'
|
|
1705
|
+
|
|
1706
|
+
def bar_chart(title, dist, span=1):
|
|
1707
|
+
if not dist:
|
|
1708
|
+
return ""
|
|
1709
|
+
mx = max(dist.values()) or 1
|
|
1710
|
+
rows = ""
|
|
1711
|
+
for lbl, cnt in sorted(dist.items(), key=lambda x: -x[1])[:25]:
|
|
1712
|
+
pct = cnt / mx * 100
|
|
1713
|
+
rows += (f'<div class="bar-row">'
|
|
1714
|
+
f'<span class="bar-label">{lbl}</span>'
|
|
1715
|
+
f'<div class="bar">'
|
|
1716
|
+
f'<div class="bar-fill" style="width:{pct:.1f}%"></div></div>'
|
|
1717
|
+
f'<span class="bar-count">{cnt:,}</span></div>')
|
|
1718
|
+
sp_style = f'grid-column:span {span};' if span > 1 else ''
|
|
1719
|
+
return (f'<div class="card" style="{sp_style}">'
|
|
1720
|
+
f'<h3>{title}</h3><div class="bar-wrap">{rows}</div></div>')
|
|
1721
|
+
|
|
1722
|
+
badges_html = (
|
|
1723
|
+
badge(f"{inv.get('total_cubes',0):,} cubes") +
|
|
1724
|
+
badge(f"{inv.get('valid_cubes',0):,} valid", "green") +
|
|
1725
|
+
(badge(f"{inv.get('corrupt_cubes',0):,} corrupt", "red")
|
|
1726
|
+
if inv.get("corrupt_cubes") else "")
|
|
1727
|
+
)
|
|
1728
|
+
|
|
1729
|
+
counts_card = card("Counts", {
|
|
1730
|
+
"Total cubes": inv.get("total_cubes"),
|
|
1731
|
+
"Valid cubes": inv.get("valid_cubes"),
|
|
1732
|
+
"Corrupt cubes": inv.get("corrupt_cubes"),
|
|
1733
|
+
})
|
|
1734
|
+
imbalance_card = card("Class Imbalance", {
|
|
1735
|
+
"Imbalance ratio": lb.get("class_imbalance_ratio"),
|
|
1736
|
+
})
|
|
1737
|
+
|
|
1738
|
+
index_html = ""
|
|
1739
|
+
for name, data in si.items():
|
|
1740
|
+
index_html += card(f"{name.upper()} (per-cube mean)",
|
|
1741
|
+
data.get("per_cube_mean", {}))
|
|
1742
|
+
|
|
1743
|
+
texture_html = "".join(
|
|
1744
|
+
card(k.replace("_"," ").title(), v) for k, v in tx.items())
|
|
1745
|
+
|
|
1746
|
+
pca_html = card("PCA Summary", {
|
|
1747
|
+
"Components for 95% variance": pc.get("n_components_95pct_mean"),
|
|
1748
|
+
})
|
|
1749
|
+
|
|
1750
|
+
html = f"""<!DOCTYPE html>
|
|
1751
|
+
<html lang="en"><head><meta charset="UTF-8"/>
|
|
1752
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
1753
|
+
<title>VisEDA — Hyperspectral Report</title>
|
|
1754
|
+
<style>
|
|
1755
|
+
:root{{--bg:white;--surface:#f6f8fa;--border:#d0d7de;--text:#1f2328;
|
|
1756
|
+
--muted:#57606a;--accent:#58a6ff;--green:#3fb950;--red:#f78166;
|
|
1757
|
+
--yellow:#e3b341;}}
|
|
1758
|
+
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
1759
|
+
body{{background:var(--bg);color:var(--text);
|
|
1760
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
1761
|
+
padding:2rem;}}
|
|
1762
|
+
h1{{font-size:1.9rem;margin-bottom:.25rem}}
|
|
1763
|
+
h2{{font-size:1.05rem;color:var(--accent);margin:1.8rem 0 .6rem}}
|
|
1764
|
+
h3{{font-size:.78rem;color:var(--muted);text-transform:uppercase;
|
|
1765
|
+
letter-spacing:.05em;margin-bottom:.5rem}}
|
|
1766
|
+
.sub{{color:var(--muted);font-size:.85rem;margin-bottom:1.5rem}}
|
|
1767
|
+
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.9rem}}
|
|
1768
|
+
.card{{background:var(--surface);border:1px solid var(--border);
|
|
1769
|
+
border-radius:8px;padding:1rem}}
|
|
1770
|
+
.stat{{display:flex;justify-content:space-between;font-size:.82rem;
|
|
1771
|
+
padding:.18rem 0;border-bottom:1px solid var(--border)}}
|
|
1772
|
+
.stat:last-child{{border-bottom:none}}
|
|
1773
|
+
.val{{color:var(--accent);font-variant-numeric:tabular-nums}}
|
|
1774
|
+
.badge{{display:inline-block;padding:.15rem .5rem;border-radius:12px;
|
|
1775
|
+
font-size:.72rem;font-weight:600;margin:.15rem}}
|
|
1776
|
+
.badge-blue{{background:rgba(88,166,255,.15);color:var(--accent)}}
|
|
1777
|
+
.badge-green{{background:rgba(63,185,80,.15);color:var(--green)}}
|
|
1778
|
+
.badge-red{{background:rgba(247,129,102,.15);color:var(--red)}}
|
|
1779
|
+
.bar-wrap{{margin-top:.4rem}}
|
|
1780
|
+
.bar-row{{display:flex;align-items:center;gap:.4rem;margin:.18rem 0;font-size:.76rem}}
|
|
1781
|
+
.bar-label{{width:120px;overflow:hidden;text-overflow:ellipsis;
|
|
1782
|
+
white-space:nowrap;color:var(--muted)}}
|
|
1783
|
+
.bar{{flex:1;background:var(--border);border-radius:3px;height:9px}}
|
|
1784
|
+
.bar-fill{{height:100%;border-radius:3px;background:var(--accent)}}
|
|
1785
|
+
.bar-count{{width:55px;text-align:right;color:var(--accent)}}
|
|
1786
|
+
footer{{margin-top:3rem;color:var(--muted);font-size:.72rem;
|
|
1787
|
+
border-top:1px solid var(--border);padding-top:1rem}}
|
|
1788
|
+
</style></head><body>
|
|
1789
|
+
<h1>🌈 VisEDA — Hyperspectral EDA Report</h1>
|
|
1790
|
+
<p class="sub">Generated by <strong>VisEDA</strong></p>
|
|
1791
|
+
<p style="margin-bottom:1rem">{badges_html}</p>
|
|
1792
|
+
|
|
1793
|
+
<h2>📦 Inventory</h2>
|
|
1794
|
+
<div class="grid">
|
|
1795
|
+
{counts_card}
|
|
1796
|
+
{bar_chart("Label Distribution", inv.get("label_distribution") or {}, span=2)}
|
|
1797
|
+
{bar_chart("Band Count Distribution", {str(k): v for k, v in inv.get("band_distribution", {}).items()})}
|
|
1798
|
+
{bar_chart("Format Distribution", inv.get("format_distribution", {}))}
|
|
1799
|
+
</div>
|
|
1800
|
+
|
|
1801
|
+
<h2>📐 Spatial</h2>
|
|
1802
|
+
<div class="grid">
|
|
1803
|
+
{card("Height (px)", sp.get("height", {}))}
|
|
1804
|
+
{card("Width (px)", sp.get("width", {}))}
|
|
1805
|
+
{card("Bands", sp.get("bands", {}))}
|
|
1806
|
+
{card("File Size (KB)", sp.get("file_size_kb", {}))}
|
|
1807
|
+
</div>
|
|
1808
|
+
|
|
1809
|
+
<h2>〰️ Spectral Statistics</h2>
|
|
1810
|
+
<div class="grid">
|
|
1811
|
+
{card("Global Mean (per cube)", ss.get("global_mean", {}))}
|
|
1812
|
+
{card("Global Std (per cube)", ss.get("global_std", {}))}
|
|
1813
|
+
{card("Dynamic Range (per cube)", ss.get("dynamic_range", {}))}
|
|
1814
|
+
</div>
|
|
1815
|
+
|
|
1816
|
+
<h2>🔬 Spectral Quality</h2>
|
|
1817
|
+
<div class="grid">
|
|
1818
|
+
{card("SNR (per cube)", sq.get("snr_mean", {}))}
|
|
1819
|
+
{card("Spectral Smoothness", sq.get("spectral_smoothness", {}))}
|
|
1820
|
+
{card("Inter-band Correlation", sq.get("inter_band_corr", {}))}
|
|
1821
|
+
{card("Dropout Bands (per cube)", sq.get("n_dropout_bands", {}))}
|
|
1822
|
+
</div>
|
|
1823
|
+
|
|
1824
|
+
<h2>🌿 Spectral Indices</h2>
|
|
1825
|
+
<div class="grid">{index_html}</div>
|
|
1826
|
+
|
|
1827
|
+
<h2>🧱 Texture (GLCM on PC1)</h2>
|
|
1828
|
+
<div class="grid">{texture_html}</div>
|
|
1829
|
+
|
|
1830
|
+
<h2>📊 PCA</h2>
|
|
1831
|
+
<div class="grid">{pca_html}</div>
|
|
1832
|
+
|
|
1833
|
+
<h2>🏷️ Labels</h2>
|
|
1834
|
+
<div class="grid">
|
|
1835
|
+
{bar_chart("Label Distribution", lb.get("label_distribution") or {}, span=2)}
|
|
1836
|
+
{imbalance_card}
|
|
1837
|
+
</div>
|
|
1838
|
+
|
|
1839
|
+
<footer>Generated by VisEDA — Visual Exploratory Data Analysis</footer>
|
|
1840
|
+
</body></html>"""
|
|
1841
|
+
|
|
1842
|
+
Path(output_path).write_text(html, encoding="utf-8")
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
def _fmt(v) -> str:
|
|
1846
|
+
if v is None: return "N/A"
|
|
1847
|
+
if isinstance(v, float): return f"{v:,.4f}"
|
|
1848
|
+
if isinstance(v, int): return f"{v:,}"
|
|
1849
|
+
return str(v)
|