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.
@@ -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)