| """Shared model/Grad-CAM code, extracted from |
| mit-group8-explainable-defect-detection.ipynb (sections 3 and 6) so the |
| Space app and the training script can both import it without duplication.""" |
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import torchvision.transforms as T |
| import matplotlib |
|
|
| CLASS_NAMES = [ |
| "crazing", |
| "inclusion", |
| "patches", |
| "pitted_surface", |
| "rolled-in_scale", |
| "scratches", |
| ] |
| IMAGE_SIZE = 128 |
| MEAN = (0.5, 0.5, 0.5) |
| STD = (0.25, 0.25, 0.25) |
|
|
| eval_transform = T.Compose([ |
| T.Resize((IMAGE_SIZE, IMAGE_SIZE)), |
| T.ToTensor(), |
| T.Normalize(MEAN, STD), |
| ]) |
|
|
| _mean_t = torch.tensor(MEAN).view(3, 1, 1) |
| _std_t = torch.tensor(STD).view(3, 1, 1) |
|
|
|
|
| def to_image(tensor): |
| return (tensor.detach().cpu() * _std_t + _mean_t).clamp(0, 1).permute(1, 2, 0).numpy() |
|
|
|
|
| class ConvBlock(nn.Module): |
| def __init__(self, in_channels, out_channels, stride): |
| super().__init__() |
| self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, |
| padding=1, bias=False) |
| self.norm = nn.BatchNorm2d(out_channels) |
| self.activation = nn.ReLU() |
|
|
| def forward(self, x): |
| return self.activation(self.norm(self.conv(x))) |
|
|
|
|
| class DefectCNN(nn.Module): |
| def __init__(self, number_of_classes=6): |
| super().__init__() |
| self.features = nn.Sequential( |
| ConvBlock(3, 24, stride=2), |
| ConvBlock(24, 48, stride=2), |
| ConvBlock(48, 96, stride=2), |
| ConvBlock(96, 128, stride=1), |
| ) |
| self.pool = nn.AdaptiveAvgPool2d(1) |
| self.classifier = nn.Sequential( |
| nn.Dropout(0.20), |
| nn.Linear(128, number_of_classes), |
| ) |
|
|
| def forward(self, x): |
| features = self.features(x) |
| return self.classifier(self.pool(features).flatten(1)) |
|
|
|
|
| class GradCAM: |
| def __init__(self, model, target_layer): |
| self.model = model |
| self.target_layer = target_layer |
| self.activations = None |
| self.gradients = None |
| self.forward_handle = target_layer.register_forward_hook(self._capture_activations) |
|
|
| def _capture_activations(self, module, inputs, output): |
| self.activations = output |
| if output.requires_grad: |
| output.register_hook(self._capture_gradients) |
|
|
| def _capture_gradients(self, gradient): |
| self.gradients = gradient |
|
|
| def __call__(self, images, target_classes): |
| self.model.eval() |
| self.model.zero_grad(set_to_none=True) |
|
|
| with torch.enable_grad(): |
| logits = self.model(images) |
| target_classes = target_classes.to(logits.device, dtype=torch.long) |
| selected_logits = logits.gather(1, target_classes[:, None]).sum() |
| selected_logits.backward() |
|
|
| weights = self.gradients.mean(dim=(2, 3), keepdim=True) |
| cam = torch.relu((weights * self.activations).sum(dim=1, keepdim=True)) |
| cam = F.interpolate(cam, size=images.shape[-2:], mode="bilinear", align_corners=False) |
|
|
| flat = cam.flatten(start_dim=1) |
| minimum = flat.min(dim=1).values[:, None, None, None] |
| maximum = flat.max(dim=1).values[:, None, None, None] |
| cam = (cam - minimum) / (maximum - minimum + 1e-8) |
| return cam[:, 0].detach(), logits.detach() |
|
|
| def close(self): |
| self.forward_handle.remove() |
|
|
|
|
| def overlay_heatmap(image_tensor, heatmap, heatmap_weight=0.45): |
| image = to_image(image_tensor) |
| color = matplotlib.colormaps["inferno"](heatmap.detach().cpu().numpy())[..., :3] |
| return np.clip((1 - heatmap_weight) * image + heatmap_weight * color, 0, 1) |
|
|
|
|
| def load_model(checkpoint_path, device="cpu"): |
| model = DefectCNN(number_of_classes=len(CLASS_NAMES)).to(device) |
| state_dict = torch.load(checkpoint_path, map_location=device) |
| model.load_state_dict(state_dict) |
| model.eval() |
| return model |
|
|