Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Clustering Hyperspectral Data: From Spectral Signatures to Thematic Maps

University of Manitoba
Planet Labs PBC
Open In Colab

Welcome to the last lesson in the Analysis and Applications module.

In the previous lessons, we focused on managing the high dimensionality of hyperspectral data using Principal Component Analysis (PCA) to reduce dimensionality, remove noise, and reconstruct cleaner hyperspectral cubes.

In this lesson, we move from dimensionality reduction to clustering, an unsupervised machine learning technique that groups similar pixels together based on their spectral signatures—without requiring predefined class labels.

Why is clustering useful for hyperspectral data?

In many real-world applications, we don’t know in advance what materials or land-cover types are present in an image. Clustering allows us to explore the data and discover natural groupings that may represent distinct surface types, such as different vegetation conditions, soil types, water bodies, or urban materials.

The key question clustering helps us answer is:

Do the pixels in this hyperspectral image naturally form meaningful groups based on their spectral signatures?

What you’ll learn in this lesson:

  • How to prepare hyperspectral data for clustering

  • How k-means clustering groups pixels based on spectral similarity

  • How to interpret clustering results using spatial maps and mean spectral signatures

  • How to compare clustering results from different input features (full cube, RGB, PCA components)

Hyperspectral Cube for This Lesson

We will continue using the same agricultural scene from the previous lesson near Campo Verde, Mato Grosso, Brazil. This scene is well-suited for clustering because it contains diverse land-cover types, including:

  • Agricultural fields with varying crop conditions

  • Water bodies

  • Roads and bare soil

  • Different vegetation types

This diversity makes it a useful test case for exploring whether k-means clustering can identify and separate distinct spectral groups based on surface characteristics.

Tanager-1 thumbnail

Scene: Tanager-1 scene near Campo Verde, Mato Grosso, Brazil.

Imports and global settings

The next cell imports all packages used in the lesson and keeps global settings in one place.

🧰 Your Toolkit so far

The reference solution for your full toolkit through Module 4 · Lesson 3 — loader, band/RGB helpers, safe index math, flatten_cube/run_pca, and the spectral-similarity metrics. Keep it at the top and run it first. This capstone adds the final pieces: select_wavelengths and the K-means workflow helpers.

# ==================
# 🧰 YOUR TOOLKIT  (reference solution so far — through Module 4 · Lesson 3)
# ==================

# Essential imports
import h5py
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score

# Reproducibility for PCA / clustering
RANDOM_STATE = 31

# ---- setup -------------------------------------------------------------

def download_tanager_data(url, file_path):
    import os
    import urllib.request

    def _progress(count, block_size, total_size):
        mb_done = count * block_size / 1_000_000
        mb_total = total_size / 1_000_000
        print(f"\rDownloading... {mb_done:.1f} / {mb_total:.1f} MB", end="", flush=True)

    if not os.path.exists(file_path):
        urllib.request.urlretrieve(url, file_path, reporthook=_progress)
        print(f"\nDownload complete: {file_path}")
    else:
        print(f"File already exists, skipping download: {file_path}")
    return file_path

# ---- io ----------------------------------------------------------------

def load_hdf5(
    file_path,
    data_path='HDFEOS/GRIDS/HYP/Data Fields/surface_reflectance',
    nodata_path='HDFEOS/GRIDS/HYP/Data Fields/nodata_pixels',
    clean=True,
    band=None
    ):

    with h5py.File(file_path, 'r') as f:
        dset = f[data_path]
        # select all or a single band
        if band is None:
            data = dset[:].astype(np.float32)
        else:
            data = dset[band].astype(np.float32)
        attrs = dict(dset.attrs)

        wavelengths = np.asarray(attrs['wavelengths'], dtype=np.float32)
        good = np.asarray(attrs['good_wavelengths'], dtype=bool)
        no_data = f[nodata_path][:].astype(bool)

    if clean:
        if band is None:
            data = data[good]
            wavelengths = wavelengths[good]
            data[:, no_data] = np.nan
            good = good[good]
        else:
            # For a single band, check if it is good, else return all nan
            wavelengths = np.array([wavelengths[band]], dtype=np.float32)
            if not good[band]:
                data = np.full_like(data, np.nan)
            else:
                data[no_data] = np.nan
            good = np.array([good[band]], dtype=bool)

    attrs['wavelengths'] = wavelengths
    attrs['good_wavelengths'] = good

    return data, attrs, no_data

# ---- bands -------------------------------------------------------------

def nearest_band(wavelengths, target_nm):
    return int(np.argmin(np.abs(np.asarray(wavelengths) - target_nm)))

# ---- transform ---------------------------------------------------------

def flatten_cube(cube, no_data=None):

    bands, rows, cols = cube.shape
    X = cube.reshape(bands, rows * cols).T
    valid_mask = np.isfinite(X).all(axis=1)
    if no_data is not None:
        valid_mask &= ~no_data.ravel()
    X_valid = X[valid_mask]
    return X, X_valid, valid_mask

def run_pca(X, n_components=10, scale=False, random_state=RANDOM_STATE):
    scaler = None
    X_input = X
    if scale:
        scaler = StandardScaler()
        X_input = scaler.fit_transform(X)
    pca_model = PCA(n_components=n_components, random_state=random_state)
    X_pca = pca_model.fit_transform(X_input)
    return X_pca, pca_model, scaler


# ---- visualization ----------------------------------------------------

def make_rgb(cube, wavelengths, r_nm=680, g_nm=550, b_nm=470,
             percentile_low=2, percentile_high=98, reference_cube=None):
    """Create a display RGB using per-channel percentile stretching.

    If `reference_cube` is provided, display limits are computed from both cubes
    so before/after RGB images use the same stretch.
    """
    r = nearest_band(wavelengths, r_nm)
    g = nearest_band(wavelengths, g_nm)
    b = nearest_band(wavelengths, b_nm)
    band_indices = (r, g, b)

    rgb = np.dstack([cube[idx] for idx in band_indices]).astype(np.float32)
    if reference_cube is None:
        stretch_rgb = rgb
    else:
        reference_rgb = np.dstack([reference_cube[idx] for idx in band_indices]).astype(np.float32)
        stretch_rgb = np.concatenate([rgb, reference_rgb], axis=0)

    rgb_display = np.zeros_like(rgb, dtype=np.float32)
    for channel in range(3):
        p_low, p_high = np.nanpercentile(
            stretch_rgb[:, :, channel],
            (percentile_low, percentile_high)
        )
        if not np.isfinite(p_low) or not np.isfinite(p_high) or p_high <= p_low:
            continue
        rgb_display[:, :, channel] = (rgb[:, :, channel] - p_low) / (p_high - p_low)

    return np.clip(rgb_display, 0, 1)
# Download the Tanager-1 Ortho SR HDF5 file using the provided
# download_tanager_data helper (defined just above).
url = "https://storage.googleapis.com/open-cogs/planet-stac/tanager1-release2-core-imagery/ortho_sr_hdf5/20250501_143138_87_4001_ortho_sr_hdf5.h5"
file_path = "20250501_143138_87_4001_ortho_sr_hdf5.h5"
download_tanager_data(url, file_path)
File already exists, skipping download: 20250501_143138_87_4001_ortho_sr_hdf5.h5
'20250501_143138_87_4001_ortho_sr_hdf5.h5'

Unsupervised and Supervised Learning

Before introducing K-means, it is important to understand the difference between supervised learning and unsupervised learning.

In supervised learning, the dataset contains both the input data and the correct labels. For example, in hyperspectral image classification, each pixel may have spectral values from many bands, and each pixel may also have a label such as vegetation, soil, water, or urban area. The model learns from these labeled examples and then uses what it learned to classify new pixels.

In unsupervised learning, the dataset does not include labels. The algorithm only receives the input data, such as an array or table of pixel values, and tries to find patterns or groups by itself. This is useful when we have hyperspectral data but do not have reference labels telling us what each pixel represents.

In the second case, the algorithm does not know whether a pixel is vegetation, soil, water, or another material. It only sees the numerical values and tries to group pixels that are similar, as illustrated in Figure 1.

Labeled and unlabeled data structures for machine learning

Figure 1:Labeled vs. unlabeled data structure for machine learning.

What is K-means?

K-means is an unsupervised clustering algorithm that groups pixels with similar spectral signatures together. The name tells you exactly what it does:

  • K = the number of clusters you want to create

  • Means = the algorithm finds the mean (average) spectral signature for each cluster

How does K-means work with hyperspectral data?

Each pixel in a hyperspectral image is represented by a spectral signature—a vector of reflectance values across hundreds of bands. For Tanager-1 data with 426 bands, each pixel is a 400+-dimensional vector.

K-means compares these vectors and groups pixels with similar spectral signatures into the same cluster. The resulting clusters often correspond to distinct materials or land-cover types, such as vegetation, soil, water, or different crop conditions.


The K-means Algorithm

K-means works by minimizing the variance within each cluster. It uses an iterative 5-step process:

1. Choose the number of clusters (K)

You must decide how many clusters to create. For example:

  • K = 3 might separate major classes found in the image like “Water,” “Vegetation,” and “Soil”

  • K = 10 might reveal finer distinctions like different crop types or soil moisture levels

2. Initialize cluster centroids

The algorithm randomly selects K starting points (centroids) from the data. Each centroid represents the “center” of a cluster.

3. Assign pixels to nearest centroid

Every pixel is assigned to the cluster whose centroid is closest, typically using Euclidean distance (the straight-line distance in high-dimensional space).

4. Update centroids

After all pixels are assigned, recalculate each centroid as the mean spectral signature of all pixels in that cluster.

5. Repeat until convergence

Steps 3 and 4 repeat until the centroids stop moving and cluster assignments stabilize, as shown in Figure 2.

K-means clustering animation showing iterative assignment and centroid update steps

Figure 2:How K-means clustering works.
The algorithm starts with initial centroids, assigns each point to the nearest centroid, updates the centroid locations, and repeats until the clusters stabilize.

Mathematical objective:

K-means minimizes the Within-Cluster Sum of Squares (WCSS):

J=∑j=1K∑xi∈Cj∥xi−μj∥2J = \sum_{j=1}^{K} \sum_{x_i \in C_j} \| x_i - \mu_j \|^2

Where:

  • JJ = total variance to minimize

  • KK = number of clusters

  • xix_i = a pixel’s spectral signature

  • μj\mu_j = centroid of cluster jj

  • ∥xi−μj∥2\| x_i - \mu_j \|^2 = squared distance between pixel and centroid

Now that we have established the foundation of K-means, it is time to move from theory to execution.

Building Your Final Toolkit Additions

This capstone adds the last pieces to your Toolkit: select_wavelengths (optional spectral subsetting on top of the file’s good_wavelengths) and the K-means workflow helpers (run_kmeans_clustering, labels_to_map, plot_kmeans_cluster_map, run_and_plot_kmeans, plot_cluster_maps). Each is defined inline where it’s first used, and the complete library is collected at the end of the lesson.

Preprocessing Considerations Before Clustering

Before applying K-means, we need to prepare the hyperspectral data carefully. The preprocessing choices we make will strongly influence the clustering results.

Key preprocessing decisions:

  1. Which bands to include - Remove noisy or atmospheric absorption bands

  2. Which pixels to mask - Exclude clouds, shadows, and no-data pixels

  3. Whether to normalize - Raw reflectance vs. standardized values

  4. Whether to reduce dimensionality - Use all bands vs. PCA components

Why does this matter for K-means?

K-means groups pixels based on Euclidean distance in feature space. This means:

  • Bands with larger values or variance have more influence on the clustering

  • In vegetation scenes, NIR bands (high reflectance) may dominate over visible bands (lower reflectance)

  • The clustering may emphasize brightness differences rather than spectral shape differences

For this lesson, we’ll start with as-retrieved reflectance values and compare results across different feature sets.

Lesson Approach

In this lesson, we’ll compare three clustering approaches:

  1. Full hyperspectral cube - using all spectral bands directly

  2. RGB bands only - using just three visible wavelengths

  3. PCA components - using dimensionality-reduced features

By comparing these results, you’ll see how input feature selection affects clustering quality and learn to interpret cluster maps using both spatial patterns and spectral signatures.

Loading and Data Preparation

The original HDF5 file stores the hyperspectral cube as (bands, rows, columns).

Our load_tanager_hdf5 function automatically handles the initial preprocessing by dropping bad bands and setting no-data pixels to NaN, so we can proceed directly to preparing the data for K-means clustering.

cube, attrs, no_data = load_hdf5(file_path)

wavelengths = attrs['wavelengths']

bands, rows, cols = cube.shape

print(f"Cube shape: {cube.shape}  (bands, rows, cols)")
print(f"Wavelength range after good_wavelengths mask: {wavelengths.min():.1f}–{wavelengths.max():.1f} nm")
Cube shape: (368, 752, 784)  (bands, rows, cols)
Wavelength range after good_wavelengths mask: 376.4–2499.0 nm

Preprocessing for K-means

We’ve already dropped bad bands and masked invalid pixels. The next step is to flatten the hyperspectral cube from 3D (bands, rows, cols) to 2D (pixels, bands) format, which both PCA and K-means require.

Optional: Additional band filtering
The next code cell allows you to filter specific wavelength ranges. For example, you could run K-means on only the visible, NIR, or SWIR portions of the spectrum.

Optional spectral subsetting: select_wavelengths

Your loader already drops the file’s flagged bad bands. Sometimes you also want to restrict clustering to a wavelength range (e.g. visible-only, or excluding noisy water-vapour bands). select_wavelengths returns a Boolean keep-mask you can apply to both the cube and the wavelength array — it’s the last general helper we add to the Toolkit.

# Added to your Toolkit this lesson: a flexible wavelength keep-mask.
def select_wavelengths(wavelengths, min_nm=None, max_nm=None, exclude_ranges=None):
    """Return a Boolean mask for wavelengths to keep.

    Parameters
    ----------
    wavelengths : ndarray
        Array of wavelength values in nanometers.
    min_nm : float, optional
        Minimum wavelength to keep (inclusive).
    max_nm : float, optional
        Maximum wavelength to keep (inclusive).
    exclude_ranges : list of tuples, optional
        List of (low, high) wavelength ranges to exclude.

    Returns
    -------
    keep : ndarray, dtype=bool
        Boolean mask where True indicates wavelengths to keep.
    """
    keep = np.ones_like(wavelengths, dtype=bool)
    if min_nm is not None:
        keep &= wavelengths >= min_nm
    if max_nm is not None:
        keep &= wavelengths <= max_nm
    if exclude_ranges is not None:
        for low, high in exclude_ranges:
            keep &= ~((wavelengths >= low) & (wavelengths <= high))
    return keep
# Optional wavelength filtering after the file's built-in good_wavelengths mask.
# For example, you could later run the K-means analysis on only visible, NIR, or SWIR wavelengths.

# Set to True to apply additional wavelength filtering, False to use all good wavelengths
APPLY_EXTRA_WAVELENGTH_FILTER = True
MIN_WAVELENGTH_NM = 370
MAX_WAVELENGTH_NM = 2500
EXCLUDE_WAVELENGTH_RANGES = None  # Example: [(1350, 1450), (1800, 1950)]

# Apply the wavelength filter based on the settings above
if APPLY_EXTRA_WAVELENGTH_FILTER:
    keep = select_wavelengths(
        wavelengths,
        min_nm=MIN_WAVELENGTH_NM,
        max_nm=MAX_WAVELENGTH_NM,
        exclude_ranges=EXCLUDE_WAVELENGTH_RANGES,
    )
else:
    # Keep all wavelengths (no additional filtering)
    keep = np.ones_like(wavelengths, dtype=bool)

# Create the filtered cube and wavelength array
cube_clean = cube[keep]
wavelengths_clean = wavelengths[keep]
bands_clean, rows_clean, cols_clean = cube_clean.shape

# Print summary of filtering results
print(f"Bands before extra filtering: {bands}")
print(f"Bands after extra filtering:  {bands_clean}")
print(f"Clean wavelength range: {wavelengths_clean.min():.1f}–{wavelengths_clean.max():.1f} nm")
Bands before extra filtering: 368
Bands after extra filtering:  368
Clean wavelength range: 376.4–2499.0 nm

The next cell flattens the cube from (bands, rows, columns) to (pixels, bands) and filters out invalid pixels (NaN or no-data) using the flatten_cube helper function.

# Flatten the cleaned data cube.
X_all_pixels, X_valid, valid_mask = flatten_cube(cube_clean, no_data=no_data)

# Save the spatial shape of the image for later reconstruction or visualization
image_shape = (rows_clean, cols_clean)

# Print out the shapes to confirm successful flattening and filtering
print(f"Feature matrix for all pixels: {X_all_pixels.shape}")
print(f"Valid feature matrix:         {X_valid.shape}")
print(f"Valid pixels: {X_valid.shape[0]:,} / {X_all_pixels.shape[0]:,}")
print(f"Features per valid pixel: {X_valid.shape[1]}")
Feature matrix for all pixels: (589568, 368)
Valid feature matrix:         (422557, 368)
Valid pixels: 422,557 / 589,568
Features per valid pixel: 368

Running K-means

Now that our cube has been flattened, the data is in the correct format for running K-means using scikit-learn.

Scikit-learn implements K-means as an estimator object. We first create a KMeans object with the desired parameters, then apply it to the data.

A typical call looks like this:

from sklearn.cluster import KMeans

kmeans = KMeans(
    n_clusters=6,
    init="k-means++",
    n_init="auto",
    random_state=RANDOM_STATE
)

labels = kmeans.fit_predict(X_valid)

Key parameters:

  • n_clusters - Number of clusters to find

  • init - Method for initializing cluster centers (default: "k-means++" for better convergence)

  • n_init - Number of times to run K-means with different initializations (use "auto" to let scikit-learn choose)

  • random_state - Random seed for reproducibility

What fit_predict returns:

The labels array contains one cluster ID for each input pixel. Each value is an integer (e.g., [0, 2, 2, 1, 0, 3, ...]) representing which cluster that pixel was assigned to.

Reusable Helper Functions for K-means

The code cell below defines helper functions for the K-means workflow. Since we’ll run K-means multiple times with different configurations, these functions reduce code repetition and keep the analysis cells focused on interpretation.

These functions:

  • Run K-means clustering on valid pixels

  • Convert cluster labels back to a 2D spatial map

  • Visualize single cluster maps

  • Execute the full K-means workflow (cluster + visualize)

  • Compare multiple cluster maps side by side

# K-means workflow helpers (added to your Toolkit this lesson)

def run_kmeans_clustering(X_valid, n_clusters=5, random_state=None, n_init=10):
    """Fit KMeans on the valid pixels; returns (labels_valid, kmeans)."""
    kmeans = KMeans(n_clusters=n_clusters, random_state=random_state, n_init=n_init)
    labels_valid = kmeans.fit_predict(X_valid)
    print(f"K-means completed with {n_clusters} clusters")
    return labels_valid, kmeans


def labels_to_map(labels_valid, valid_mask, image_shape, invalid_label=np.nan):
    """Scatter 1-D cluster labels back into a 2-D image map (invalid pixels = NaN)."""
    valid_mask_flat = valid_mask.ravel()
    labels_full = np.full(valid_mask_flat.shape[0], invalid_label, dtype=float)
    labels_full[valid_mask_flat] = labels_valid
    return labels_full.reshape(image_shape)


def plot_kmeans_cluster_map(cluster_map, n_clusters, cube, wavelengths, title="K-means Cluster Map"):
    """Plot the RGB composite (via make_rgb) next to a K-means cluster map."""
    rgb_img = make_rgb(cube, wavelengths)
    fig, axes = plt.subplots(1, 2, figsize=(16, 6))
    axes[0].imshow(rgb_img)
    axes[0].set_title("RGB Composite", fontsize=12)
    axes[0].axis("off")
    cmap = plt.get_cmap("tab10", n_clusters).copy()
    img = axes[1].imshow(np.ma.masked_invalid(cluster_map), cmap=cmap,
                         vmin=-0.5, vmax=n_clusters - 0.5)
    axes[1].set_title(title, fontsize=12)
    axes[1].axis("off")
    cbar = fig.colorbar(img, ax=axes[1], ticks=range(n_clusters), fraction=0.046, pad=0.04)
    cbar.set_label("Cluster ID")
    plt.tight_layout()
    plt.show()


def run_and_plot_kmeans(X, valid_mask, image_shape, n_clusters, random_state,
                        cube, wavelengths, plot_title="K-means Cluster Map"):
    """Run K-means, map labels back to image space, and plot; returns (map, labels, kmeans)."""
    labels_valid, kmeans = run_kmeans_clustering(
        X_valid=X, n_clusters=n_clusters, random_state=random_state, n_init=10)
    kmeans_map = labels_to_map(labels_valid=labels_valid, valid_mask=valid_mask,
                               image_shape=image_shape)
    plot_kmeans_cluster_map(kmeans_map, n_clusters, cube, wavelengths, title=plot_title)
    return kmeans_map, labels_valid, kmeans


def plot_cluster_maps(maps, titles=None, N_CLUSTERS=5, cmap_name="tab20",
                      figsize_scale=(7, 6), colorbar_label="Cluster ID",
                      show=True, separate_colorbars=False):
    """Plot any number of cluster maps side by side (shared or separate colorbars)."""
    n_maps = len(maps)
    if titles is None:
        titles = [f"Map {i+1}" for i in range(n_maps)]
    elif len(titles) < n_maps:
        titles = titles + [f"Map {i+1}" for i in range(len(titles), n_maps)]
    fig, axes = plt.subplots(1, n_maps, figsize=(figsize_scale[0]*n_maps, figsize_scale[1]))
    if n_maps == 1:
        axes = [axes]
    cmap = plt.get_cmap(cmap_name, N_CLUSTERS).copy()
    imshow_kwargs = {"cmap": cmap, "vmin": -0.5, "vmax": N_CLUSTERS - 0.5}
    ims = []
    for i, (m, ax) in enumerate(zip(maps, axes)):
        im = ax.imshow(np.ma.masked_invalid(m), **imshow_kwargs)
        ax.set_title(titles[i])
        ax.axis("off")
        ims.append(im)
        if separate_colorbars:
            cbar = fig.colorbar(im, ax=ax, ticks=range(N_CLUSTERS), fraction=0.046, pad=0.04)
            cbar.set_label(colorbar_label)
    if not separate_colorbars:
        cbar = fig.colorbar(ims[-1], ax=axes, ticks=range(N_CLUSTERS), fraction=0.046, pad=0.04)
        cbar.set_label(colorbar_label)
    if show:
        plt.show()
    return fig, axes

In the cell below, we will define the number of clusters that will be used for the rest of the lesson.

# Set the number of clusters to use for K-means
N_CLUSTERS = 5

K-means Using the Full Hyperspectral Cube

Run K-means on all spectral bands using the reflectance values.

# Run K-means clustering on the full hyperspectral cube data (all available bands/features).
kmeans_full_map, labels_valid_full, kmeans_full = run_and_plot_kmeans(
    X_valid,
    valid_mask, # from flatten code
    image_shape,
    N_CLUSTERS,
    RANDOM_STATE,
    cube_clean,
    wavelengths_clean,
    plot_title="K-means Cluster Map Using Full Hyperspectral Cube"
)
K-means completed with 5 clusters
<Figure size 1600x600 with 3 Axes>

Interpreting the Results

With K = 5, the clustering algorithm has identified five distinct spectral groups across the scene. The spatial distribution shows clear patterns:

  • Agricultural fields are separated into distinct clusters, likely reflecting differences in crop type, growth stage, or soil background

  • Dense vegetation surrounding water bodies form their own cluster with consistent grouping along what appear to be waterways

  • Bright surfaces (possibly roads or bare soil) are grouped separately

  • The vegetation-dominated landscape shows multiple clusters, suggesting spectral variation within vegetated areas

These initial results demonstrate that K-means can identify meaningful spectral groups using the full hyperspectral information. Now, let’s explore how the result changes when K-means uses only the RGB bands.

K-means Using RGB Bands Only

RGB clustering is a useful baseline because it shows what can be separated using only visible color information.

# Select RGB band indices from the list of cleaned wavelengths:
# - The nearest_band function finds the band index closest to the desired wavelength.
rgb_indices = [
    nearest_band(wavelengths_clean, 680),  # Red channel
    nearest_band(wavelengths_clean, 560),  # Green channel
    nearest_band(wavelengths_clean, 470),  # Blue channel
]

# Create the RGB feature matrix (X_rgb) by extracting the RGB band values for all valid pixels.
# This forms a (n_valid_pixels, 3) matrix for clustering with K-means.
X_rgb = X_valid[:, rgb_indices]

# Run K-means clustering using only the RGB bands and plot the resulting cluster map.
kmeans_rgb_map, labels_valid_rgb, kmeans_rgb = run_and_plot_kmeans(
    X_rgb,
    valid_mask,
    image_shape,
    N_CLUSTERS,
    RANDOM_STATE,
    cube_clean,
    wavelengths_clean,
    plot_title="K-means Cluster Map Using RGB Bands"
)
K-means completed with 5 clusters
<Figure size 1600x600 with 3 Axes>

Interpreting the RGB Results

The RGB-based clustering shows considerably more noise and the clusters are not as spatially cohesive when compared to the full hyperspectral result. With only three bands, K-means has limited spectral information to distinguish between land-cover types. Materials with similar visible colors but different compositions (such as different crop types or vegetation conditions) cannot be separated effectively using RGB data alone.

Let’s compare the full hyperspectral clustering with the RGB-only result to see this difference more clearly.

# Compare full hyperspectral vs RGB clustering
plot_cluster_maps(
    maps=[kmeans_full_map, kmeans_rgb_map],
    titles=["K-means Using All Bands", "K-means Using RGB Bands"],
    N_CLUSTERS=N_CLUSTERS,
    separate_colorbars=True
)
<Figure size 1400x600 with 4 Axes>
(<Figure size 1400x600 with 4 Axes>, array([<Axes: title={'center': 'K-means Using All Bands'}>, <Axes: title={'center': 'K-means Using RGB Bands'}>], dtype=object))

The side-by-side comparison reveals the limitation of RGB clustering. With only three bands, many agricultural fields fragment into multiple clusters or merge incorrectly with neighboring areas. This happens because RGB cannot distinguish materials with similar visible colors but different near-infrared or shortwave-infrared reflectance—such as crops at different growth stages or vegetation with varying water content.

The full hyperspectral result maintains more consistent cluster assignments because it uses spectral information across the visible, NIR, and SWIR regions.

Next, let’s examine whether PCA can achieve similar clustering quality while reducing dimensionality.

K-means Using PCA Components

PCA can reduce noise and dimensionality before clustering. This often speeds up K-means and can reduce the influence of weak or noisy bands.

We’ll use the first 3 PCA components as the feature space for K-means.

# Number of PCA components to use for clustering (we use first 3 components)
PCA_COMPONENTS_FOR_CLUSTERING = 3

# Run PCA on the valid spectral pixels (no scaling here)
X_pca, pca_model, pca_scaler = run_pca(
    X_valid,
    n_components=PCA_COMPONENTS_FOR_CLUSTERING,
    scale=False,
    random_state=RANDOM_STATE,
)

# Print the shape of the new feature matrix (n_samples, n_components)
print(f"PCA feature matrix shape: {X_pca.shape}")

# Show the explained variance for each principal component
print("Explained variance ratio:")
for i, ratio in enumerate(pca_model.explained_variance_ratio_, start=1):
    print(f"PC{i}: {ratio:.4f}")

# Print total variance explained by all chosen components
print(f"Total explained variance: {pca_model.explained_variance_ratio_.sum():.4f}")
PCA feature matrix shape: (422557, 3)
Explained variance ratio:
PC1: 0.7486
PC2: 0.2347
PC3: 0.0071
Total explained variance: 0.9904
# Run K-means clustering on the PCA-transformed data and plot the resulting cluster map
kmeans_pca_map, labels_valid_pca, kmeans_pca = run_and_plot_kmeans(
    X_pca,
    valid_mask,
    image_shape,
    N_CLUSTERS,
    RANDOM_STATE,
    cube_clean,
    wavelengths_clean,
    plot_title="K-means Cluster Map Using PCA Components"
)
K-means completed with 5 clusters
<Figure size 1600x600 with 3 Axes>

Let’s compare the full hyperspectral clustering with the PCA-based result.

# Compare full hyperspectral vs PCA clustering
plot_cluster_maps(
    maps=[kmeans_full_map, kmeans_pca_map],
    titles=["K-means Using All Bands", "K-means Cluster Map Using PCA Components"],
    N_CLUSTERS=N_CLUSTERS,
    separate_colorbars=True
)
<Figure size 1400x600 with 4 Axes>
(<Figure size 1400x600 with 4 Axes>, array([<Axes: title={'center': 'K-means Using All Bands'}>, <Axes: title={'center': 'K-means Cluster Map Using PCA Components'}>], dtype=object))

The K-means results of the full hyperspectral cube against those from PCA components are very similar. Only slight pixel-level differences appear between the two results (after you account for different cluster IDs and assigned colors). This suggests that the first three PCA components preserved most of the important spectral information needed for clustering, while reducing the dimensionality of the data.

Let’s put the three results side by side and write the final conclusion.

# Compare all three clustering approaches
plot_cluster_maps(
    maps=[kmeans_full_map, kmeans_rgb_map, kmeans_pca_map],
    titles=["K-means Using All Bands", "K-means Using RGB Bands", "K-means Using PCA Components"],
    N_CLUSTERS=N_CLUSTERS,
    separate_colorbars=True
)
<Figure size 2100x600 with 6 Axes>
(<Figure size 2100x600 with 6 Axes>, array([<Axes: title={'center': 'K-means Using All Bands'}>, <Axes: title={'center': 'K-means Using RGB Bands'}>, <Axes: title={'center': 'K-means Using PCA Components'}>], dtype=object))

Comparing the three K-means results shows how the input data affects the clustering quality. The result using all hyperspectral bands produces the most meaningful spatial pattern, where the clusters separate different land-cover conditions more clearly.

The RGB-based result is less consistent. Many fields are split into several clusters, and some areas are shifted into different groups. This happens because RGB bands contain only limited visible information, so materials with similar colors are difficult to separate.

The result using PCA components is closer to the full hyperspectral result. Although some pixel-level differences appear, the main spatial patterns and land-cover separations are still preserved. This shows that PCA keeps most of the important spectral information for clustering this scene while reducing the number of bands.

Overall, this comparison shows that hyperspectral data provide richer information than RGB for K-means clustering. PCA can also be an effective alternative because it reduces data dimensionality while maintaining most of the useful spectral variation.

To confirm this visual interpretation, we can also compare the cluster maps quantitatively using similarity metrics such as Adjusted Rand Index and Normalized Mutual Information, which will show how close the RGB-based and PCA-based results are to the full hyperspectral result.

First, though, we’ll pause to better understand some preprocessing decisions critical to clustering analysis.

Understanding the Role of Preprocessing and Normalization

Now that you’ve seen how different input features affect clustering results, let’s explore the preprocessing decisions in more depth. Understanding these choices will help you make better decisions when applying K-means to your own hyperspectral data.

General Preprocessing Framework

Before applying K-means to hyperspectral data, a systematic preprocessing workflow should include:

  1. Define the objective - What should the clusters represent? Land-cover types, crop conditions, moisture differences?

  2. Remove bad or noisy bands - Exclude bands affected by low signal-to-noise ratio, atmospheric absorption, or water vapor regions.

  3. Mask invalid or irrelevant pixels - Exclude clouds, shadows, no-data pixels, and areas outside the study region.

  4. Decide whether brightness should matter - Raw reflectance contains both brightness and spectral shape. Is one or both important for your application?

  5. Choose an appropriate normalization method - This determines what kind of spectral differences K-means will emphasize.

  6. Consider dimensionality reduction - PCA or other methods can reduce noise and improve computational efficiency.

  7. Interpret and validate - Use spectral profiles, spatial patterns, field knowledge, and reference data to evaluate results.

The Normalization Decision

Quantitative comparison of cluster maps

Because K-means cluster IDs are arbitrary, we cannot compare the maps by simply checking whether the label numbers are the same. For example, Cluster 1 in one map may correspond to Cluster 3 in another map. Therefore, we use permutation-invariant metrics that measure similarity between cluster maps without depending on the exact cluster ID numbers.

  • Adjusted Rand Index (ARI): Measures how similarly pairs of pixels are grouped in two maps. A value of 1 means perfect agreement, while values close to 0 indicate agreement similar to random clustering.

  • Normalized Mutual Information (NMI): Measures how much information is shared between two clustering results. A value of 1 means the two maps contain almost the same clustering information.

In this comparison, the full-cube K-means result is used as the reference because it contains all the bands (i.e., all possible information). The RGB-based and PCA-based clustering maps are compared against it. These metrics help us quantify how similar the cluster maps are, but they do not prove that one clustering result is the true or scientifically correct classification. They only measure agreement between different clustering outputs.

def compare_cluster_maps(reference_map, target_map):
    """
    Compare two cluster maps using permutation-invariant similarity metrics.
    
    Because K-means assigns cluster IDs arbitrarily, we cannot directly compare
    cluster labels between two independent runs. Instead, this function uses
    metrics that measure clustering agreement regardless of how the clusters
    are numbered.
    
    Parameters
    ----------
    reference_map : ndarray, shape (rows, cols)
        First cluster map (typically the baseline for comparison).
    target_map : ndarray, shape (rows, cols)
        Second cluster map to compare against the reference.
    
    Returns
    -------
    metrics : dict
        Dictionary containing:
        - 'adjusted_rand_index': Measures similarity of pixel groupings.
          Values range from -1 to 1, where 1 = perfect agreement,
          0 = random clustering, negative = worse than random.
        - 'normalized_mutual_information': Measures shared information
          between clusterings. Values range from 0 to 1, where
          1 = identical clustering structure, 0 = no shared information.
    
    Notes
    -----
    - Both maps must have the same shape.
    - NaN and inf values are automatically excluded from the comparison.
    - Only pixels that are valid (finite) in BOTH maps are used.
    """
    # Flatten both maps to 1D arrays
    ref = reference_map.ravel()
    tgt = target_map.ravel()
    
    # Keep only pixels that are finite (not NaN, not inf) in both maps
    valid = np.isfinite(ref) & np.isfinite(tgt)
    ref_comp = ref[valid]
    tgt_comp = tgt[valid]

    return {
        "adjusted_rand_index": adjusted_rand_score(ref_comp, tgt_comp),
        "normalized_mutual_information": normalized_mutual_info_score(ref_comp, tgt_comp),
    }
# Run comparisons between the full hyperspectral K-means map and the RGB and PCA maps

# Initialize an empty list to store comparison results for each pair of cluster maps
comparison_rows = []

# Loop through the pairs of cluster maps to compare and compute similarity metrics
for name, cluster_map in [
    ("RGB vs all bands", kmeans_rgb_map),
    ("PCA vs all bands", kmeans_pca_map),
]:
    scores = compare_cluster_maps(kmeans_full_map, cluster_map)
    comparison_rows.append({"comparison": name, **scores})

# Create a DataFrame from the comparison results and display it
comparison_df = pd.DataFrame(comparison_rows)
comparison_df
Loading...

The quantitative results support our previous visual comparison. The RGB vs all bands comparison has a low ARI value of 0.232 and a low NMI value of 0.349, which means the RGB-based clustering has weak agreement with the full hyperspectral clustering. This confirms that using only RGB bands loses a large amount of spectral information, causing many pixels and fields to be grouped differently.

In contrast, the PCA vs all bands comparison shows extremely high agreement, with an ARI of 0.996 and an NMI of 0.991. This means the PCA-based clustering is almost identical to the clustering obtained from the full hyperspectral cube. Therefore, PCA successfully preserves the most important spectral information needed for K-means clustering while reducing the number of input bands.

Overall, these results show that RGB is not sufficient to reproduce the full hyperspectral clustering, while PCA provides a very close approximation to the full-band result.

After comparing the cluster maps visually and quantitatively, the next step is to look more deeply at why the PCA-based K-means result is so close to the full hyperspectral result. To do this, we examine the data in the PCA feature space using the first two principal components, PC1 and PC2.

These two components summarize the main spectral variation in the hyperspectral cube. By plotting the pixels in PC1–PC2 space, we can see how the K-means clusters are separated and where the cluster centers are located. We can also compare this with the PC1 and PC2 image maps to understand what kind of spatial and spectral information each component captures.

This analysis helps explain how PCA reduces the hyperspectral data into fewer dimensions while still preserving the most important information needed for clustering.

PCA Feature-Space Diagnostics for K-means Clustering

The diagnostic visualization below shows how pixels are distributed in PC1–PC2 space, along with the K-means cluster centers, spatial maps of each component, and the final cluster map.

If clusters are well separated in PCA space and their spatial patterns align with field boundaries, then the first two principal components captured the spectral variation that drives clustering in the full hyperspectral data.

def plot_pca_2d_clustering_diagnostics(
    X_pca,
    labels_valid_pca,
    kmeans_pca,
    kmeans_pca_map,
    pc1_map,
    pc2_map,
    n_clusters
):
    """
    Visualize K-means clustering results in PCA feature space with diagnostic plots.
    
    Creates a 6-panel figure showing:
    - Scatter plot of pixels in PC1-PC2 space with cluster assignments
    - False-color composite of PC1 and PC2
    - Spatial cluster map
    - Individual PC1 and PC2 spatial maps
    
    Parameters
    ----------
    X_pca : ndarray, shape (n_pixels, 2)
        PCA-transformed data (first 2 components).
    labels_valid_pca : ndarray
        Cluster labels for each pixel.
    kmeans_pca : KMeans
        Fitted KMeans object.
    kmeans_pca_map : ndarray, shape (rows, cols)
        2D cluster map.
    pc1_map : ndarray, shape (rows, cols)
        Spatial map of PC1.
    pc2_map : ndarray, shape (rows, cols)
        Spatial map of PC2.
    n_clusters : int
        Number of clusters.
    
    Returns
    -------
    fig : Figure
        Matplotlib figure object.
    axes : ndarray
        Array of axes objects.
    """

    def normalize(arr):
        low, high = np.nanpercentile(arr, [2, 98])
        return np.clip((arr - low) / (high - low), 0, 1)

    cmap = plt.get_cmap("tab10", n_clusters)

    pca_rgb = np.dstack([
        normalize(pc1_map),
        normalize(pc2_map),
        np.zeros_like(pc1_map)
    ])

    fig, axes = plt.subplots(2, 3, figsize=(18, 10))

    ax = axes[0, 0]
    ax.scatter(
        X_pca[:, 0],
        X_pca[:, 1],
        c=labels_valid_pca,
        cmap=cmap,
        s=4,
        alpha=0.5
    )
    ax.scatter(
        kmeans_pca.cluster_centers_[:, 0],
        kmeans_pca.cluster_centers_[:, 1],
        c="black",
        s=150,
        marker="X",
        label="Centers"
    )
    ax.set(
        title="K-means clusters in PCA space",
        xlabel="PC1",
        ylabel="PC2"
    )
    ax.legend()

    axes[0, 1].imshow(pca_rgb)
    axes[0, 1].set_title("False-color composite: PC1 (red), PC2 (green)")

    im = axes[0, 2].imshow(
        np.ma.masked_invalid(kmeans_pca_map),
        cmap=cmap,
        vmin=-0.5,
        vmax=n_clusters - 0.5
    )
    axes[0, 2].set_title("Spatial K-means map from PCA features")
    fig.colorbar(im, ax=axes[0, 2], ticks=range(n_clusters), label="Cluster ID")

    axes[1, 0].imshow(pc1_map, cmap="gray")
    axes[1, 0].set_title("PC1 spatial map")

    axes[1, 1].imshow(pc2_map, cmap="gray")
    axes[1, 1].set_title("PC2 spatial map")

    for ax in axes.ravel()[1:]:
        ax.axis("off")

    plt.tight_layout()
    plt.show()
    
    return fig, axes
# Select the first two PCA components for clustering
X_pca_2 = X_pca[:, :2]

# Run K-means clustering using only PC1 and PC2
kmeans_pca2_map, labels_valid_pca2, kmeans_pca2 = run_and_plot_kmeans(
    X_pca_2,
    valid_mask,
    image_shape,
    N_CLUSTERS,
    RANDOM_STATE,
    cube_clean,
    wavelengths_clean,
    plot_title="K-means Cluster Map Using First 2 PCA Components"
)

# Reconstruct spatial maps for PC1 and PC2 (needed for diagnostic visualization)
# Create an array to hold all pixels (valid + invalid) for both components
pca_2comp_full = np.full(
    (valid_mask.ravel().shape[0], 2),
    np.nan,
    dtype=np.float32
)

# Fill in the valid pixel locations with their PC1 and PC2 values
pca_2comp_full[valid_mask.ravel()] = X_pca_2

# Reshape from flat array back to 2D image with 2 channels (PC1, PC2)
pca_2comp_maps = pca_2comp_full.reshape(
    image_shape[0],
    image_shape[1],
    2
)

# Extract individual spatial maps for PC1 and PC2
pc1_2comp_map = pca_2comp_maps[:, :, 0]
pc2_2comp_map = pca_2comp_maps[:, :, 1]

print(f"PCA 2-component maps shape: {pca_2comp_maps.shape}")
K-means completed with 5 clusters
<Figure size 1600x600 with 3 Axes>
PCA 2-component maps shape: (752, 784, 2)
fig, axes = plot_pca_2d_clustering_diagnostics(
    X_pca=X_pca_2,
    labels_valid_pca=labels_valid_pca2,
    kmeans_pca=kmeans_pca2,
    kmeans_pca_map=kmeans_pca2_map,
    pc1_map=pc1_2comp_map,
    pc2_map=pc2_2comp_map,
    n_clusters=N_CLUSTERS
)
<Figure size 1800x1000 with 7 Axes>

The cluster centers are well distributed across the PCA space, showing that K-means is finding distinct spectral groups rather than assigning pixels randomly.

The point cloud has a fan-like/triangular shape because PC1 and PC2 are linear combinations of the original hyperspectral bands. PCA compresses the majority of the data variance into only two linear axes, so complex spectral relationships are projected into a simplified 2D feature space. This can make some clusters overlap near the center, especially for mixed pixels or transitional areas where spectral differences are weak.

This also highlights a limitation of PCA: it is a linear dimensionality reduction method, so it may not fully capture non-linear spectral patterns in hyperspectral data (of which there can be many!). For more complex structures, non-linear methods such as t-SNE, UMAP, or autoencoders may reveal separations that PCA cannot show clearly. You can see an example of UMAP results at the end of the dimensionality reduction lesson.

Be careful with Elbow and Silhouette scores

Caution when interpreting the best K with the elbow method and silhouette score in hyperspectral clustering

Figure 3:Elbow and silhouette diagnostics for selecting the number of clusters in hyperspectral K-means. These scores must be interpreted carefully alongside spectral and spatial knowledge.

As shown in Figure 3, in hyperspectral clustering, you may use diagnostic plots and scores to better understand the “optimal” number of clusters. However, inertia and silhouette scores can be misleading. The elbow curve may not show a clear break, and the silhouette score may suggest a value of K that is not meaningful in relation to the actual land-cover classes present in an image.

This happens because hyperspectral data are high-dimensional and can be highly correlated. Pixels often contain mixed materials and have varying illumination effects and within-class spectral variability.

Therefore, the “best” number of clusters should not be selected only from these numerical scores. Instead, K should be chosen using spectral interpretation, including the mean spectral signature of each cluster, spatial consistency, field knowledge, and comparison with ground reference data.

Choosing K using spectral interpretation

Instead of relying only on inertia or silhouette scores, we can examine how the mean spectral signatures change as the number of clusters increases.

In this step, K-means is run for different values of K from 2 to 10. For each K, we plot:

  • the mean spectrum of each cluster,

  • the spectral variability within each cluster using ±1 standard deviation,

  • the number of pixels assigned to each cluster.

This helps us understand whether increasing K creates meaningful new spectral groups or only splits existing clusters into small or noisy groups.

A good value of K should produce clusters with distinct spectral signatures, reasonable pixel counts, and meaningful spatial patterns. The goal is not just to maximize a score, but to choose a K that makes sense from a hyperspectral and land-cover interpretation point of view.

# Multi-K spectral signature plot + cluster pixel-count bars
# This creates a grid of plots showing how spectral signatures and cluster sizes change as K varies from 2 to 10

k_values_to_explore = [2, 3, 4, 5, 6, 7, 8, 9, 10]

n_rows = len(k_values_to_explore)

fig, axes = plt.subplots(
    n_rows,
    2,
    figsize=(17, 4.8 * n_rows),
    gridspec_kw={"width_ratios": [3, 1.2]}
)

# Use tab10 colormap, allowing up to 10 unique colors
colors_palette = plt.get_cmap("tab10").colors

for ax_idx, k in enumerate(k_values_to_explore):

    ax_spec = axes[ax_idx, 0]
    ax_bar = axes[ax_idx, 1]

    # Fit K-means for the current value of K
    km = KMeans(n_clusters=k, random_state=RANDOM_STATE, n_init="auto")
    km.fit(X_valid)
    labels_k = km.predict(X_valid)

    cluster_counts = []

    # For each cluster, compute and plot the mean spectrum with ±1 std dev shading
    for cid in range(k):
        mask_c = labels_k == cid
        cluster_spectra = X_valid[mask_c]

        # Calculate mean and standard deviation of spectral signatures for this cluster
        mean_spec = np.mean(cluster_spectra, axis=0)
        std_spec = np.std(cluster_spectra, axis=0)

        cluster_count = mask_c.sum()
        cluster_counts.append(cluster_count)

        color = colors_palette[cid % len(colors_palette)]

        ax_spec.plot(
            wavelengths_clean,
            mean_spec,
            color=color,
            linewidth=1.8,
            label=f"C{cid} (n={cluster_count:,})"
        )

        ax_spec.fill_between(
            wavelengths_clean,
            mean_spec - std_spec,
            mean_spec + std_spec,
            color=color,
            alpha=0.15
        )

    # Spectral plot formatting
    ax_spec.set_title(
        f"K = {k}: Mean Spectrum ± 1 Std Dev",
        fontsize=13,
        fontweight="bold"
    )
    ax_spec.set_xlabel("Wavelength (nm)", fontsize=10)
    ax_spec.set_ylabel("Reflectance", fontsize=10)
    ax_spec.grid(alpha=0.2)
    ax_spec.legend(fontsize=8, loc="upper right")

    # Bar plot: number of pixels per cluster
    cluster_ids = np.arange(k)
    bar_colors = [colors_palette[cid % len(colors_palette)] for cid in cluster_ids]

    bars = ax_bar.bar(
        cluster_ids,
        cluster_counts,
        color=bar_colors,
        edgecolor="black",
        alpha=0.85
    )

    ax_bar.set_title("Pixels per Cluster", fontsize=12, fontweight="bold")
    ax_bar.set_xlabel("Cluster ID", fontsize=10)
    ax_bar.set_ylabel("Number of Pixels", fontsize=10)
    ax_bar.set_xticks(cluster_ids)
    ax_bar.set_xticklabels([f"C{cid}" for cid in cluster_ids], rotation=45)
    ax_bar.grid(axis="y", alpha=0.2)

    # Give extra vertical space so labels are not cut off
    max_count = max(cluster_counts)
    ax_bar.set_ylim(0, max_count * 1.25)

    # Add pixel count labels above each bar (rotated 90° to save space)
    for bar, count in zip(bars, cluster_counts):
        ax_bar.text(
            bar.get_x() + bar.get_width() / 2,
            bar.get_height() + max_count * 0.02,
            f"{count:,}",
            ha="center",
            va="bottom",
            fontsize=9,
            rotation=90,
            bbox=dict(
                facecolor="white",
                edgecolor="none",
                alpha=0.75,
                pad=1.5
            )
        )

plt.suptitle(
    "How Clusters 'Split' as K Increases\nMean Spectrum ± 1 Std Dev and Pixel Count per Cluster",
    fontsize=15,
    fontweight="bold",
    y=0.995)

plt.tight_layout()
plt.show()
<Figure size 1700x4320 with 18 Axes>

The multi-K spectral signature plots show that increasing the number of clusters does not only split the data numerically; it also reveals new spectral groups with different physical meaning. As K increases, some broad clusters begin to separate into more specific surface conditions, such as different vegetation states, soil/background variation, and wet or low-reflectance materials.

An important observation is that around K = 7, a distinct spectral signature related to water starts to appear. This suggests that smaller K values were merging water or wet areas with other low-reflectance surfaces, while higher K values allow K-means to isolate this material more clearly.

For this reason, we continued testing up to K = 10 to see whether additional meaningful spectral groups appear. The next step is to visualize the thematic cluster map for K = 10 and examine whether these spectral differences also form meaningful spatial patterns.

# Set the number of clusters to use for K-means
N_CLUSTERS = 10

kmeans_full_map, labels_valid_full, kmeans_full = run_and_plot_kmeans(
    X_valid,
    valid_mask,
    image_shape,
    N_CLUSTERS,
    RANDOM_STATE,
    cube_clean,
    wavelengths_clean,
    plot_title="K-means Cluster Map Using Full Hyperspectral Cube"
)
K-means completed with 10 clusters
<Figure size 1600x600 with 3 Axes>

With K = 10 and the full hyperspectral cube, we can distinguish more variability in the scene. More detailed surface differences appear, including vegetation variation, soil/background differences, roads, and wet areas. Importantly, the deeper water is now separated more clearly and appears as the gray cluster, Cluster 7, on the map. This shows that increasing K can reveal meaningful spectral classes that were hidden or merged at lower K values.

Assignment

In the previous lesson, we learned how to reconstruct the PCA components to create a cleaner hyperspectral cube with reduced noise. Your assignment is to replicate what you learned in this lesson using the denoised hyperspectral cube and compare the results.

Beyond K-means: What’s Next?

K-means is a useful starting point, but it is only one way to cluster hyperspectral data. It is a hard clustering method, meaning each pixel is assigned to only one cluster.

In real hyperspectral images, this is not always realistic. A single pixel may contain a mixture of vegetation, soil, water, or shadow. This leads to other approaches:

  • Soft clustering, such as Gaussian Mixture Models, where a pixel can belong to multiple clusters with different probabilities.

  • Spectral unmixing, where each pixel is treated as a mixture of several pure materials, called endmembers.

  • Density-based clustering, such as DBSCAN, which can find irregular cluster shapes and identify noise or outliers.

These methods open the door to a deeper question: Are we trying to assign each pixel to one class, or understand what materials are mixed inside each pixel?

A great resource we recommend for learning more about unsupervised learning is the scikit-learn documentation. It provides clear explanations, examples, and practical implementations of clustering, dimensionality reduction, mixture models, and other unsupervised learning methods.

You can start here:

https://scikit-learn.org/stable/unsupervised_learning.html


🧰 Your Complete Hyperspectral Toolkit

That’s a wrap on the build-your-own-toolkit thread. Over Modules 2–4 you grew a single, reusable library — from reading and cleaning a Tanager-1 cube, through band selection, spectral indices, dimensionality reduction and denoising, to unsupervised clustering.

The cell below collects the entire reference toolkit in one place. Keep it as your starting point for any new hyperspectral analysis — it’s yours now.

# ==================
# 🧰 YOUR COMPLETE TOOLKIT  (Modules 2–4 — the whole reference library)
# ==================

# Essential imports
import h5py as h5
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score


# Reproducibility for PCA / clustering
RANDOM_STATE = 31


# ---- setup -------------------------------------------------------------

def download_tanager_data(url, file_path):
    import os
    import urllib.request

    def _progress(count, block_size, total_size):
        mb_done = count * block_size / 1_000_000
        mb_total = total_size / 1_000_000
        print(f"\rDownloading... {mb_done:.1f} / {mb_total:.1f} MB", end="", flush=True)

    if not os.path.exists(file_path):
        urllib.request.urlretrieve(url, file_path, reporthook=_progress)
        print(f"\nDownload complete: {file_path}")
    else:
        print(f"File already exists, skipping download: {file_path}")
    return file_path


# ---- io ----------------------------------------------------------------

def print_structure(name, obj):
    level = name.count('/')
    indent = '    ' * level
    if isinstance(obj, h5.Group):
        print(f"{indent}📂 {name}/")
    elif isinstance(obj, h5.Dataset):
        print(f"{indent}📄 {name} | {obj.shape} | {obj.dtype}")
    if len(obj.attrs) > 0:
        for key, val in obj.attrs.items():
            val_str = str(val)
            if len(val_str) > 50:
                val_str = val_str[:50] + "..."
            print(f"{indent}    ↳ 🏷️ {key}: {val_str}")

def load_hdf5(
    file_path,
    data_path='HDFEOS/GRIDS/HYP/Data Fields/surface_reflectance',
    nodata_path='HDFEOS/GRIDS/HYP/Data Fields/nodata_pixels',
    clean=True,
    band=None
    ):

    with h5py.File(file_path, 'r') as f:
        dset = f[data_path]
        # select all or a single band
        if band is None:
            data = dset[:].astype(np.float32)
        else:
            data = dset[band].astype(np.float32)
        attrs = dict(dset.attrs)

        wavelengths = np.asarray(attrs['wavelengths'], dtype=np.float32)
        good = np.asarray(attrs['good_wavelengths'], dtype=bool)
        no_data = f[nodata_path][:].astype(bool)

    if clean:
        if band is None:
            data = data[good]
            wavelengths = wavelengths[good]
            data[:, no_data] = np.nan
            good = good[good]
        else:
            # For a single band, check if it is good, else return all nan
            wavelengths = np.array([wavelengths[band]], dtype=np.float32)
            if not good[band]:
                data = np.full_like(data, np.nan)
            else:
                data[no_data] = np.nan
            good = np.array([good[band]], dtype=bool)

    attrs['wavelengths'] = wavelengths
    attrs['good_wavelengths'] = good

    return data, attrs, no_data


# ---- bands -------------------------------------------------------------

def nearest_band(wavelengths, target_nm):
    """Return the index of the band whose wavelength is closest to ``target_nm``."""
    return int(np.argmin(np.abs(np.asarray(wavelengths) - target_nm)))

def get_band(cube, wavelengths, target_nm, verbose=True):
    """Return the 2-D band nearest ``target_nm`` (uses ``nearest_band``).

    Parameters
    ----------
    cube : ndarray, shape (bands, rows, cols)
        Hyperspectral cube.
    wavelengths : ndarray
        Per-band center wavelengths (nm).
    target_nm : float
        Requested wavelength (nm).
    verbose : bool, optional
        If True, print the actual wavelength used and its shift from the request.

    Returns
    -------
    ndarray, shape (rows, cols)
        The 2-D band nearest ``target_nm``.
    """
    idx = nearest_band(wavelengths, target_nm)
    actual_nm = float(wavelengths[idx])
    if verbose:
        print(
            f"Requested {target_nm} nm -> using {actual_nm:.2f} nm "
            f"(shift: {actual_nm - target_nm:+.2f} nm)"
        )
    return cube[idx, :, :]

def select_wavelengths(wavelengths, min_nm=None, max_nm=None, exclude_ranges=None):
    """Return a Boolean mask for wavelengths to keep.

    Parameters
    ----------
    wavelengths : ndarray
        Array of wavelength values in nanometers.
    min_nm : float, optional
        Minimum wavelength to keep (inclusive).
    max_nm : float, optional
        Maximum wavelength to keep (inclusive).
    exclude_ranges : list of tuples, optional
        List of (low, high) wavelength ranges to exclude.

    Returns
    -------
    keep : ndarray, dtype=bool
        Boolean mask where True indicates wavelengths to keep.
    """
    keep = np.ones_like(wavelengths, dtype=bool)
    if min_nm is not None:
        keep &= wavelengths >= min_nm
    if max_nm is not None:
        keep &= wavelengths <= max_nm
    if exclude_ranges is not None:
        for low, high in exclude_ranges:
            keep &= ~((wavelengths >= low) & (wavelengths <= high))
    return keep


# ---- transform ---------------------------------------------------------


def flatten_cube(cube, no_data=None):
    bands, rows, cols = cube.shape
    X = cube.reshape(bands, rows * cols).T
    valid_mask = np.isfinite(X).all(axis=1)
    if no_data is not None:
        valid_mask &= ~no_data.ravel()
    X_valid = X[valid_mask]
    return X, X_valid, valid_mask

def run_pca(X, n_components=10, scale=False, random_state=RANDOM_STATE):
    scaler = None
    X_input = X
    if scale:
        scaler = StandardScaler()
        X_input = scaler.fit_transform(X)
    pca_model = PCA(n_components=n_components, random_state=random_state)
    X_pca = pca_model.fit_transform(X_input)
    return X_pca, pca_model, scaler


# ---- visualization ----------------------------------------------------

def make_rgb(cube, wavelengths, r_nm=680, g_nm=550, b_nm=470,
             percentile_low=2, percentile_high=98, reference_cube=None):
    """Create a display RGB using per-channel percentile stretching.

    If `reference_cube` is provided, display limits are computed from both cubes
    so before/after RGB images use the same stretch.
    """
    r = nearest_band(wavelengths, r_nm)
    g = nearest_band(wavelengths, g_nm)
    b = nearest_band(wavelengths, b_nm)
    band_indices = (r, g, b)

    rgb = np.dstack([cube[idx] for idx in band_indices]).astype(np.float32)
    if reference_cube is None:
        stretch_rgb = rgb
    else:
        reference_rgb = np.dstack([reference_cube[idx] for idx in band_indices]).astype(np.float32)
        stretch_rgb = np.concatenate([rgb, reference_rgb], axis=0)

    rgb_display = np.zeros_like(rgb, dtype=np.float32)
    for channel in range(3):
        p_low, p_high = np.nanpercentile(
            stretch_rgb[:, :, channel],
            (percentile_low, percentile_high)
        )
        if not np.isfinite(p_low) or not np.isfinite(p_high) or p_high <= p_low:
            continue
        rgb_display[:, :, channel] = (rgb[:, :, channel] - p_low) / (p_high - p_low)

    return np.clip(rgb_display, 0, 1)

def contrast_stretching(image, lower=2, upper=98):
    """Return display limits for contrast stretching based on percentiles."""
    return np.nanpercentile(image, lower), np.nanpercentile(image, upper)

def plot_index_with_hist(index_arr,
                        index_name="Index",
                        hist_label="Value",
                        cmap='RdYlGn',
                        figsize=(10, 6),
                        vmin=None,
                        vmax=None):
    arr = index_arr
    if vmin is None or vmax is None:
        vmin, vmax = contrast_stretching(arr)

    fig, axes = plt.subplots(1, 2, figsize=figsize, gridspec_kw={'width_ratios':[2,1]})

    im = axes[0].imshow(arr, cmap=cmap, vmin=vmin, vmax=vmax)
    axes[0].set_title(f"{index_name}", fontsize=14, fontweight='bold')
    axes[0].axis('off')
    plt.colorbar(im, ax=axes[0], fraction=0.046, pad=0.04, label=index_name)

    axes[1].hist(arr[~np.isnan(arr)].ravel(),
                 bins=500,
                 alpha=0.8,
                 range=(vmin, vmax), edgecolor='k')
    axes[1].set_title(f"{index_name} Histogram", fontsize=12)
    axes[1].set_xlabel(hist_label)
    axes[1].set_ylabel("Frequency")
    axes[1].grid(alpha=0.3)

    plt.tight_layout()
    plt.show()


# ---- spectral ----------------------------------------------------------

def spectral_sam(spec1, spec2):
    """Spectral Angle Mapper (radians) between two spectra. Lower = more similar."""
    mask = np.isfinite(spec1) & np.isfinite(spec2)
    if np.sum(mask) < 2:
        return np.nan
    a = spec1[mask]
    b = spec2[mask]
    denom = np.linalg.norm(a) * np.linalg.norm(b)
    if denom == 0:
        return np.nan
    cosang = np.clip(np.dot(a, b) / denom, -1, 1)
    return np.arccos(cosang)

def spectral_corr(spec1, spec2):
    """Pearson correlation between two spectra (-1..1; higher = more similar)."""
    mask = np.isfinite(spec1) & np.isfinite(spec2)
    if np.sum(mask) < 2:
        return np.nan
    a = spec1[mask]
    b = spec2[mask]
    if np.nanstd(a) == 0 or np.nanstd(b) == 0:
        return np.nan
    return np.corrcoef(a, b)[0, 1]

def spectral_mad(spec1, spec2):
    """Mean Absolute Difference between two spectra (reflectance units)."""
    diff = np.abs(spec1 - spec2)
    return np.nanmean(diff)

def sam_map(cube1, cube2, valid_mask=None):
    """Pixelwise Spectral Angle Mapper (radians) between two cubes -> 2-D map."""
    dot = np.nansum(cube1 * cube2, axis=0)
    norm1 = np.sqrt(np.nansum(cube1**2, axis=0))
    norm2 = np.sqrt(np.nansum(cube2**2, axis=0))
    denom = norm1 * norm2
    cosang = np.full_like(dot, np.nan, dtype=float)
    good = denom > 0
    cosang[good] = dot[good] / denom[good]
    cosang = np.clip(cosang, -1, 1)
    sam = np.arccos(cosang)
    if valid_mask is not None:
        sam[~valid_mask] = np.nan
    return sam


# ---- clustering --------------------------------------------------------

def run_kmeans_clustering(X_valid, n_clusters=5, random_state=None, n_init=10):
    """Fit KMeans on the valid pixels; returns (labels_valid, kmeans)."""
    kmeans = KMeans(n_clusters=n_clusters, random_state=random_state, n_init=n_init)
    labels_valid = kmeans.fit_predict(X_valid)
    print(f"K-means completed with {n_clusters} clusters")
    return labels_valid, kmeans

def labels_to_map(labels_valid, valid_mask, image_shape, invalid_label=np.nan):
    """Scatter 1-D cluster labels back into a 2-D image map (invalid pixels = NaN)."""
    valid_mask_flat = valid_mask.ravel()
    labels_full = np.full(valid_mask_flat.shape[0], invalid_label, dtype=float)
    labels_full[valid_mask_flat] = labels_valid
    return labels_full.reshape(image_shape)

def plot_kmeans_cluster_map(cluster_map, n_clusters, cube, wavelengths, title="K-means Cluster Map"):
    """Plot the RGB composite (via make_rgb) next to a K-means cluster map."""
    rgb_img = make_rgb(cube, wavelengths)
    fig, axes = plt.subplots(1, 2, figsize=(16, 6))
    axes[0].imshow(rgb_img)
    axes[0].set_title("RGB Composite", fontsize=12)
    axes[0].axis("off")
    cmap = plt.get_cmap("tab10", n_clusters).copy()
    img = axes[1].imshow(np.ma.masked_invalid(cluster_map), cmap=cmap,
                         vmin=-0.5, vmax=n_clusters - 0.5)
    axes[1].set_title(title, fontsize=12)
    axes[1].axis("off")
    cbar = fig.colorbar(img, ax=axes[1], ticks=range(n_clusters), fraction=0.046, pad=0.04)
    cbar.set_label("Cluster ID")
    plt.tight_layout()
    plt.show()

def run_and_plot_kmeans(X, valid_mask, image_shape, n_clusters, random_state,
                        cube, wavelengths, plot_title="K-means Cluster Map"):
    """Run K-means, map labels back to image space, and plot; returns (map, labels, kmeans)."""
    labels_valid, kmeans = run_kmeans_clustering(
        X_valid=X, n_clusters=n_clusters, random_state=random_state, n_init=10)
    kmeans_map = labels_to_map(labels_valid=labels_valid, valid_mask=valid_mask,
                               image_shape=image_shape)
    plot_kmeans_cluster_map(kmeans_map, n_clusters, cube, wavelengths, title=plot_title)
    return kmeans_map, labels_valid, kmeans

def plot_cluster_maps(maps, titles=None, N_CLUSTERS=5, cmap_name="tab20",
                      figsize_scale=(7, 6), colorbar_label="Cluster ID",
                      show=True, separate_colorbars=False):
    """Plot any number of cluster maps side by side (shared or separate colorbars)."""
    n_maps = len(maps)
    if titles is None:
        titles = [f"Map {i+1}" for i in range(n_maps)]
    elif len(titles) < n_maps:
        titles = titles + [f"Map {i+1}" for i in range(len(titles), n_maps)]
    fig, axes = plt.subplots(1, n_maps, figsize=(figsize_scale[0]*n_maps, figsize_scale[1]))
    if n_maps == 1:
        axes = [axes]
    cmap = plt.get_cmap(cmap_name, N_CLUSTERS).copy()
    imshow_kwargs = {"cmap": cmap, "vmin": -0.5, "vmax": N_CLUSTERS - 0.5}
    ims = []
    for i, (m, ax) in enumerate(zip(maps, axes)):
        im = ax.imshow(np.ma.masked_invalid(m), **imshow_kwargs)
        ax.set_title(titles[i])
        ax.axis("off")
        ims.append(im)
        if separate_colorbars:
            cbar = fig.colorbar(im, ax=ax, ticks=range(N_CLUSTERS), fraction=0.046, pad=0.04)
            cbar.set_label(colorbar_label)
    if not separate_colorbars:
        cbar = fig.colorbar(ims[-1], ax=axes, ticks=range(N_CLUSTERS), fraction=0.046, pad=0.04)
        cbar.set_label(colorbar_label)
    if show:
        plt.show()
    return fig, axes