In [1]:
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from pathlib import Path
from PIL import Image
from collections import Counter
import cv2
from skimage import exposure, filters
import warnings
warnings.filterwarnings('ignore')

print("All imports OK")
All imports OK
In [2]:
# Paths
BASE_DIR = Path("../data/solar_thermal")
IMG_DIR = BASE_DIR / "images"
META_PATH = BASE_DIR / "module_metadata.json"

# Load metadata
with open(META_PATH, "r") as f:
    metadata = json.load(f)

# Convert to DataFrame
records = []
for img_id, info in metadata.items():
    records.append({
        "image_id": img_id,
        "filepath": BASE_DIR / info["image_filepath"],
        "anomaly_class": info["anomaly_class"]
    })

df = pd.DataFrame(records)

# Class distribution
class_counts = df["anomaly_class"].value_counts()

print(f"Total images: {len(df)}")
print(f"Total classes: {df['anomaly_class'].nunique()}")
print(f"\nClass distribution:")
print(class_counts.to_string())
Total images: 20000
Total classes: 12

Class distribution:
anomaly_class
No-Anomaly        10000
Cell               1877
Vegetation         1639
Diode              1499
Cell-Multi         1288
Shadowing          1056
Cracking            940
Offline-Module      827
Hot-Spot            249
Hot-Spot-Multi      246
Soiling             204
Diode-Multi         175
In [4]:
fig, ax = plt.subplots(figsize=(12, 6))

colors = ["#2ecc71" if c == "No-Anomaly" else "#e74c3c" for c in class_counts.index]

bars = ax.barh(class_counts.index, class_counts.values, color=colors)

# Value labels
for bar, val in zip(bars, class_counts.values):
    ax.text(bar.get_width() + 80, bar.get_y() + bar.get_height()/2,
            f"{val:,}", va="center", fontsize=9)

ax.set_xlabel("Image Count", fontsize=11)
ax.set_title("InfraredSolarModules — Class Distribution\n20,000 Thermal Images · 12 Classes", 
             fontsize=13, fontweight="bold")

healthy_patch = mpatches.Patch(color="#2ecc71", label="No Anomaly")
defect_patch  = mpatches.Patch(color="#e74c3c", label="Defect Class")
ax.legend(handles=[healthy_patch, defect_patch], loc="lower right")

ax.set_xlim(0, class_counts.max() + 1200)
ax.invert_yaxis()
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig("../outputs/sprint05a_class_distribution.png", dpi=150)
plt.show()
print("Chart saved.")
No description has been provided for this image
Chart saved.
In [5]:
# Pick one sample image per class
samples = df.groupby("anomaly_class").first().reset_index()

fig, axes = plt.subplots(3, 4, figsize=(14, 10))
axes = axes.flatten()

for idx, row in samples.iterrows():
    img = Image.open(row["filepath"]).convert("L")  # grayscale thermal
    img_array = np.array(img)

    ax = axes[idx]
    im = ax.imshow(img_array, cmap="inferno")
    ax.set_title(row["anomaly_class"], fontsize=10, fontweight="bold")
    ax.axis("off")
    plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)

fig.suptitle("Thermal Solar Module Inspection — One Sample Per Defect Class\nInfraredSolarModules Dataset · ICLR 2020",
             fontsize=13, fontweight="bold", y=1.01)

plt.tight_layout()
plt.savefig("../outputs/sprint05a_class_samples.png", dpi=150, bbox_inches="tight")
plt.show()
print("Sample grid saved.")
No description has been provided for this image
Sample grid saved.
In [6]:
def analyze_thermal(filepath):
    """Extract thermal statistics from a single module image."""
    img = np.array(Image.open(filepath).convert("L")).astype(np.float32)
    
    total_pixels = img.size
    mean_temp    = img.mean()
    max_temp     = img.max()
    min_temp     = img.min()
    std_temp     = img.std()
    
    # Hotspot = pixels above 90th percentile
    threshold    = np.percentile(img, 90)
    hotspot_mask = img >= threshold
    hotspot_pct  = (hotspot_mask.sum() / total_pixels) * 100
    
    # Thermal gradient — how uneven is the temperature distribution
    gradient = np.gradient(img)
    grad_magnitude = np.sqrt(gradient[0]**2 + gradient[1]**2).mean()
    
    return {
        "mean_temp": mean_temp,
        "max_temp": max_temp,
        "min_temp": min_temp,
        "std_temp": std_temp,
        "hotspot_pct": hotspot_pct,
        "thermal_gradient": grad_magnitude
    }

# Run analysis on all 20,000 images
print("Analyzing all images — this may take ~1 minute...")

results = []
for _, row in df.iterrows():
    stats = analyze_thermal(row["filepath"])
    stats["anomaly_class"] = row["anomaly_class"]
    results.append(stats)

df_stats = pd.DataFrame(results)

print(f"Done. Shape: {df_stats.shape}")
print(df_stats.groupby("anomaly_class")[["mean_temp", "hotspot_pct", "thermal_gradient"]].mean().round(2))
Analyzing all images — this may take ~1 minute...
Done. Shape: (20000, 7)
                 mean_temp  hotspot_pct  thermal_gradient
anomaly_class                                            
Cell            156.720001        11.20              5.95
Cell-Multi      167.759995        10.77              7.06
Cracking        166.910004        10.38              8.61
Diode           168.509995        10.82              6.82
Diode-Multi     177.300003        11.14              8.33
Hot-Spot        164.649994        11.51              5.12
Hot-Spot-Multi  183.649994        11.30              6.15
No-Anomaly      150.899994        11.96              4.90
Offline-Module  173.199997        11.59              6.88
Shadowing       148.750000        11.46              4.47
Soiling         170.270004        10.99              6.11
Vegetation      168.860001        11.25              6.61
In [7]:
# Aggregate stats per class
summary = df_stats.groupby("anomaly_class").agg(
    mean_temp=("mean_temp", "mean"),
    std_temp=("std_temp", "mean"),
    thermal_gradient=("thermal_gradient", "mean")
).reset_index().sort_values("mean_temp", ascending=True)

fig, axes = plt.subplots(1, 3, figsize=(16, 6))

colors = ["#2ecc71" if c == "No-Anomaly" else "#e74c3c" for c in summary["anomaly_class"]]

# Panel 1 — Mean temperature
axes[0].barh(summary["anomaly_class"], summary["mean_temp"], color=colors)
axes[0].set_title("Mean Pixel Intensity\n(proxy for temperature)", fontweight="bold")
axes[0].set_xlabel("Pixel Intensity (0–255)")
axes[0].axvline(summary[summary["anomaly_class"] == "No-Anomaly"]["mean_temp"].values[0],
                color="green", linestyle="--", alpha=0.6, label="No-Anomaly baseline")
axes[0].legend(fontsize=8)
axes[0].grid(axis="x", alpha=0.3)

# Panel 2 — Std deviation (temperature uniformity)
axes[1].barh(summary["anomaly_class"], summary["std_temp"], color=colors)
axes[1].set_title("Temp Std Deviation\n(uniformity — lower = healthier)", fontweight="bold")
axes[1].set_xlabel("Std Deviation")
axes[1].axvline(summary[summary["anomaly_class"] == "No-Anomaly"]["std_temp"].values[0],
                color="green", linestyle="--", alpha=0.6, label="No-Anomaly baseline")
axes[1].legend(fontsize=8)
axes[1].grid(axis="x", alpha=0.3)

# Panel 3 — Thermal gradient
axes[2].barh(summary["anomaly_class"], summary["thermal_gradient"], color=colors)
axes[2].set_title("Thermal Gradient\n(edge sharpness — higher = irregular heat)", fontweight="bold")
axes[2].set_xlabel("Gradient Magnitude")
axes[2].axvline(summary[summary["anomaly_class"] == "No-Anomaly"]["thermal_gradient"].values[0],
                color="green", linestyle="--", alpha=0.6, label="No-Anomaly baseline")
axes[2].legend(fontsize=8)
axes[2].grid(axis="x", alpha=0.3)

fig.suptitle("Thermal Inspection — Defect Class Analysis\nInfraredSolarModules · 20,000 Images · 12 Classes",
             fontsize=13, fontweight="bold")

healthy_patch = mpatches.Patch(color="#2ecc71", label="No Anomaly")
defect_patch  = mpatches.Patch(color="#e74c3c", label="Defect")
fig.legend(handles=[healthy_patch, defect_patch], loc="lower center", ncol=2, fontsize=10)

plt.tight_layout()
plt.savefig("../outputs/sprint05a_thermal_analysis.png", dpi=150, bbox_inches="tight")
plt.show()
print("Analysis chart saved.")
No description has been provided for this image
Analysis chart saved.
In [10]:
def plot_hotspot_detection(anomaly_class, threshold_pct=85):
    """Show thermal image + hotspot overlay for a given class."""
    sample = df[df["anomaly_class"] == anomaly_class].iloc[0]
    img = np.array(Image.open(sample["filepath"]).convert("L")).astype(np.float32)
    
    # Threshold
    threshold = np.percentile(img, threshold_pct)
    hotspot_mask = img >= threshold
    
    # Upscale for visibility (24x40 is tiny)
    scale = 8
    img_large = cv2.resize(img, (img.shape[1]*scale, img.shape[0]*scale), 
                           interpolation=cv2.INTER_NEAREST)
    mask_large = cv2.resize(hotspot_mask.astype(np.uint8), 
                            (img.shape[1]*scale, img.shape[0]*scale),
                            interpolation=cv2.INTER_NEAREST)
    
    fig, axes = plt.subplots(1, 3, figsize=(12, 4))
    
    # Raw thermal
    axes[0].imshow(img_large, cmap="inferno")
    axes[0].set_title("Raw Thermal Image", fontweight="bold")
    axes[0].axis("off")
    
    # Hotspot mask
    axes[1].imshow(img_large, cmap="inferno")
    overlay = np.zeros((*img_large.shape, 4))
    overlay[mask_large == 1] = [1, 0, 0, 0.5]  # red overlay
    axes[1].imshow(overlay)
    axes[1].set_title(f"Hotspot Detection\n(>{threshold_pct}th percentile)", fontweight="bold")
    axes[1].axis("off")
    
    # Temperature histogram
    axes[2].hist(img.flatten(), bins=30, color="#e74c3c", alpha=0.8, edgecolor="black", linewidth=0.5)
    axes[2].axvline(threshold, color="yellow", linewidth=2, label=f"Threshold ({threshold:.0f})")
    axes[2].set_title("Pixel Intensity Distribution", fontweight="bold")
    axes[2].set_xlabel("Intensity (proxy: temperature)")
    axes[2].set_ylabel("Pixel count")
    axes[2].legend()
    axes[2].grid(alpha=0.3)
    
    fig.suptitle(f"Hotspot Detection — Class: {anomaly_class}", 
                 fontsize=13, fontweight="bold")
    plt.tight_layout()
    return fig

# Run for the most critical defect classes
critical_classes = ["Hot-Spot-Multi", "Cell", "Cracking", "Offline-Module"]

figs = []
for cls in critical_classes:
    fig = plot_hotspot_detection(cls)
    figs.append(fig)

# Save as combined figure
fig_combined, axes_combined = plt.subplots(4, 3, figsize=(14, 18))

for i, (cls, fig) in enumerate(zip(critical_classes, figs)):
    plt.close(fig)

# Re-render combined
for i, cls in enumerate(critical_classes):
    sample = df[df["anomaly_class"] == cls].iloc[0]
    img = np.array(Image.open(sample["filepath"]).convert("L")).astype(np.float32)
    threshold = np.percentile(img, 85)
    hotspot_mask = img >= threshold
    scale = 8
    img_large = cv2.resize(img, (img.shape[1]*scale, img.shape[0]*scale),
                           interpolation=cv2.INTER_NEAREST)
    mask_large = cv2.resize(hotspot_mask.astype(np.uint8),
                            (img.shape[1]*scale, img.shape[0]*scale),
                            interpolation=cv2.INTER_NEAREST)

    axes_combined[i][0].imshow(img_large, cmap="inferno")
    axes_combined[i][0].set_title(f"{cls} — Raw", fontweight="bold", fontsize=9)
    axes_combined[i][0].axis("off")

    axes_combined[i][1].imshow(img_large, cmap="inferno")
    overlay = np.zeros((*img_large.shape, 4))
    overlay[mask_large == 1] = [1, 0, 0, 0.5]
    axes_combined[i][1].imshow(overlay)
    axes_combined[i][1].set_title(f"{cls} — Hotspot", fontweight="bold", fontsize=9)
    axes_combined[i][1].axis("off")

    axes_combined[i][2].hist(img.flatten(), bins=30, color="#e74c3c", alpha=0.8,
                              edgecolor="black", linewidth=0.5)
    axes_combined[i][2].axvline(threshold, color="yellow", linewidth=2,
                                 label=f"T={threshold:.0f}")
    axes_combined[i][2].set_xlabel("Intensity", fontsize=8)
    axes_combined[i][2].legend(fontsize=8)
    axes_combined[i][2].grid(alpha=0.3)

fig_combined.suptitle("Hotspot Detection Pipeline — Critical Defect Classes\nInfraredSolarModules · 85th Percentile Threshold",
                       fontsize=13, fontweight="bold", y=1.02)
plt.tight_layout(rect=[0, 0, 1, 0.98])
plt.savefig("../outputs/sprint05a_hotspot_detection.png", dpi=150, bbox_inches="tight")
plt.show()
print("Hotspot detection chart saved.")
No description has been provided for this image
Hotspot detection chart saved.
In [11]:
# Full statistical summary per class
summary_full = df_stats.groupby("anomaly_class").agg(
    count=("mean_temp", "count"),
    mean_temp=("mean_temp", "mean"),
    max_temp=("max_temp", "mean"),
    std_temp=("std_temp", "mean"),
    hotspot_pct=("hotspot_pct", "mean"),
    thermal_gradient=("thermal_gradient", "mean")
).reset_index().sort_values("mean_temp", ascending=False)

summary_full = summary_full.round(3)

print("=" * 75)
print("SPRINT 05A — SOLAR THERMAL INSPECTION SUMMARY")
print("Dataset: InfraredSolarModules · ICLR 2020 · 20,000 images · 12 classes")
print("=" * 75)
print(summary_full.to_string(index=False))
print("=" * 75)

# Key findings
baseline = summary_full[summary_full["anomaly_class"] == "No-Anomaly"].iloc[0]
hottest  = summary_full.iloc[0]
highest_grad = summary_full.loc[summary_full["thermal_gradient"].idxmax()]

print("\nKEY FINDINGS:")
print(f"  → Hottest defect class    : {hottest['anomaly_class']} (mean intensity {hottest['mean_temp']:.1f})")
print(f"  → No-Anomaly baseline     : mean intensity {baseline['mean_temp']:.1f}")
print(f"  → Delta hottest vs healthy: +{hottest['mean_temp'] - baseline['mean_temp']:.1f} intensity units")
print(f"  → Highest thermal gradient: {highest_grad['anomaly_class']} ({highest_grad['thermal_gradient']:.2f}) — most irregular heat")
print(f"  → Total defective images  : {len(df[df['anomaly_class'] != 'No-Anomaly']):,} / {len(df):,} ({len(df[df['anomaly_class'] != 'No-Anomaly'])/len(df)*100:.1f}%)")
===========================================================================
SPRINT 05A — SOLAR THERMAL INSPECTION SUMMARY
Dataset: InfraredSolarModules · ICLR 2020 · 20,000 images · 12 classes
===========================================================================
 anomaly_class  count  mean_temp   max_temp  std_temp  hotspot_pct  thermal_gradient
Hot-Spot-Multi    246 183.645996 222.667007 24.205999       11.299             6.152
   Diode-Multi    175 177.298996 216.399994 23.865000       11.142             8.326
Offline-Module    827 173.199005 205.863007 23.379999       11.594             6.879
       Soiling    204 170.274002 241.373001 21.993999       10.991             6.106
    Vegetation   1639 168.860001 226.947006 22.287001       11.249             6.611
         Diode   1499 168.507996 207.440002 21.393999       10.822             6.824
    Cell-Multi   1288 167.764999 229.317993 21.941000       10.770             7.059
      Cracking    940 166.906006 243.559006 27.945000       10.379             8.611
      Hot-Spot    249 164.649994 196.763000 17.664000       11.515             5.123
          Cell   1877 156.723007 214.031006 19.183001       11.198             5.950
    No-Anomaly  10000 150.904999 176.067993 16.427999       11.962             4.899
     Shadowing   1056 148.755005 183.660995 16.665001       11.458             4.474
===========================================================================

KEY FINDINGS:
  → Hottest defect class    : Hot-Spot-Multi (mean intensity 183.6)
  → No-Anomaly baseline     : mean intensity 150.9
  → Delta hottest vs healthy: +32.7 intensity units
  → Highest thermal gradient: Cracking (8.61) — most irregular heat
  → Total defective images  : 10,000 / 20,000 (50.0%)
In [12]:
fig = plt.figure(figsize=(16, 10))
fig.patch.set_facecolor("#0d0d0d")

# Title
fig.text(0.5, 0.97, "Solar Panel Thermal Inspection — Defect Detection Pipeline",
         ha="center", fontsize=15, fontweight="bold", color="white")
fig.text(0.5, 0.94, "InfraredSolarModules Dataset · ICLR 2020 · 20,000 Thermal Images · 12 Defect Classes",
         ha="center", fontsize=10, color="#aaaaaa")

# Layout: 2 rows
# Row 1: 4 sample images (raw + hotspot for 2 classes)
# Row 2: bar chart summary

classes_to_show = ["Hot-Spot-Multi", "Cracking", "Offline-Module", "No-Anomaly"]
panel_positions = [
    [0.04, 0.52, 0.18, 0.36],
    [0.24, 0.52, 0.18, 0.36],
    [0.44, 0.52, 0.18, 0.36],
    [0.64, 0.52, 0.18, 0.36],
]

for pos, cls in zip(panel_positions, classes_to_show):
    sample = df[df["anomaly_class"] == cls].iloc[0]
    img = np.array(Image.open(sample["filepath"]).convert("L")).astype(np.float32)
    threshold = np.percentile(img, 85)
    hotspot_mask = img >= threshold
    scale = 8
    img_large = cv2.resize(img, (img.shape[1]*scale, img.shape[0]*scale),
                           interpolation=cv2.INTER_NEAREST)
    mask_large = cv2.resize(hotspot_mask.astype(np.uint8),
                            (img.shape[1]*scale, img.shape[0]*scale),
                            interpolation=cv2.INTER_NEAREST)

    ax = fig.add_axes(pos)
    ax.imshow(img_large, cmap="inferno")
    if cls != "No-Anomaly":
        overlay = np.zeros((*img_large.shape, 4))
        overlay[mask_large == 1] = [1, 0, 0, 0.5]
        ax.imshow(overlay)
    color = "#2ecc71" if cls == "No-Anomaly" else "#e74c3c"
    ax.set_title(cls, fontsize=9, fontweight="bold", color=color, pad=4)
    ax.axis("off")

# Hotspot overlay legend panel
ax_legend = fig.add_axes([0.84, 0.52, 0.13, 0.36])
ax_legend.set_facecolor("#1a1a1a")
ax_legend.axis("off")
ax_legend.text(0.5, 0.95, "DETECTION\nLEGEND", ha="center", va="top",
               fontsize=9, fontweight="bold", color="white", transform=ax_legend.transAxes)
ax_legend.text(0.5, 0.70, "🔴 Hotspot\n(>85th pct)", ha="center", fontsize=8,
               color="#e74c3c", transform=ax_legend.transAxes)
ax_legend.text(0.5, 0.50, "🟡 Threshold\nline", ha="center", fontsize=8,
               color="yellow", transform=ax_legend.transAxes)
ax_legend.text(0.5, 0.30, "🟢 No-Anomaly\nbaseline", ha="center", fontsize=8,
               color="#2ecc71", transform=ax_legend.transAxes)
ax_legend.text(0.5, 0.08, f"Δ Hottest\nvs Healthy\n+32.7 units", ha="center", fontsize=8,
               color="white", transform=ax_legend.transAxes)

# Row 2: summary bar chart
ax_bar = fig.add_axes([0.04, 0.08, 0.92, 0.36])
ax_bar.set_facecolor("#1a1a1a")

colors_bar = ["#2ecc71" if c == "No-Anomaly" else
              "#e74c3c" if c in ["Hot-Spot-Multi", "Diode-Multi", "Offline-Module"] else
              "#e67e22" for c in summary_full["anomaly_class"]]

bars = ax_bar.barh(summary_full["anomaly_class"], summary_full["mean_temp"],
                   color=colors_bar, edgecolor="#333333", linewidth=0.5)

for bar, val in zip(bars, summary_full["mean_temp"]):
    ax_bar.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
                f"{val:.1f}", va="center", fontsize=8, color="white")

ax_bar.axvline(baseline["mean_temp"], color="#2ecc71", linestyle="--",
               linewidth=1.5, alpha=0.8, label=f"No-Anomaly baseline ({baseline['mean_temp']:.1f})")
ax_bar.set_xlabel("Mean Pixel Intensity (temperature proxy)", color="white", fontsize=9)
ax_bar.set_title("Mean Thermal Intensity by Defect Class", color="white",
                 fontsize=11, fontweight="bold")
ax_bar.tick_params(colors="white")
ax_bar.spines[["top", "right"]].set_visible(False)
ax_bar.spines[["left", "bottom"]].set_color("#444444")
ax_bar.set_facecolor("#1a1a1a")
ax_bar.legend(fontsize=8, facecolor="#2a2a2a", labelcolor="white")
ax_bar.set_xlim(130, summary_full["mean_temp"].max() + 10)
ax_bar.invert_yaxis()
ax_bar.grid(axis="x", alpha=0.2, color="white")
for label in ax_bar.get_yticklabels():
    label.set_color("white")

plt.savefig("../outputs/sprint05a_final_map.png", dpi=150,
            bbox_inches="tight", facecolor="#0d0d0d")
plt.show()
print("Final map saved.")
No description has been provided for this image
Final map saved.
In [ ]: