"""PyTorch Attention U-Net for brain-tumor segmentation. Why PyTorch and not the existing TF segmentation_models.py? TensorFlow 2.21 on native Windows has no GPU support — only CPU — and there is no path back to TF + CUDA on Windows without dropping to Python 3.10 / TF 2.10 / CUDA 11.2 / cuDNN 8.1, or moving to WSL2. The user has an RTX 4060 with PyTorch 2.11 + CUDA 12.6 already working, so we train the U-Net in PyTorch on GPU and keep the existing TF classifier code unchanged. The architecture mirrors the Attention U-Net described in Oktay et al. (MIDL 2018) and matches the TF reference in src/segmentation_models.py: four-level encoder/decoder, attention gates on the skip connections, BatchNorm + ReLU + Dropout, binary sigmoid output. Default base_filters=64 gives ~31M params; for a small dataset on a single GPU base_filters=32 (~7M) is usually enough. """ from __future__ import annotations import torch import torch.nn as nn import torch.nn.functional as F class ConvBlock(nn.Module): """Two 3x3 conv -> BN -> ReLU with Dropout in the middle.""" def __init__(self, in_ch: int, out_ch: int, dropout: float = 0.2): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Dropout2d(dropout), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x) class AttentionGate(nn.Module): """Additive attention gate (Oktay et al.) applied to a skip connection. g (gating from decoder, upsampled to skip resolution) and x (skip from encoder) are 1x1-projected to a shared dimension, summed, ReLU'd, then 1x1-projected to a single channel + sigmoid -> attention map alpha. Output = alpha * x (element-wise gating on the skip connection). """ def __init__(self, x_ch: int, g_ch: int, inter_ch: int): super().__init__() self.theta_x = nn.Conv2d(x_ch, inter_ch, kernel_size=1, bias=False) self.phi_g = nn.Conv2d(g_ch, inter_ch, kernel_size=1, bias=False) self.psi = nn.Conv2d(inter_ch, 1, kernel_size=1, bias=True) def forward(self, x, g): if g.shape[-2:] != x.shape[-2:]: g = F.interpolate(g, size=x.shape[-2:], mode='bilinear', align_corners=False) attn = F.relu(self.theta_x(x) + self.phi_g(g), inplace=True) attn = torch.sigmoid(self.psi(attn)) return x * attn class AttentionUNet(nn.Module): """4-level Attention U-Net for binary segmentation. Input: (B, 3, H, W).""" def __init__(self, in_channels: int = 3, base_filters: int = 32, dropout: float = 0.2): super().__init__() f = base_filters # Encoder self.enc1 = ConvBlock(in_channels, f, dropout) self.enc2 = ConvBlock(f, f * 2, dropout) self.enc3 = ConvBlock(f * 2, f * 4, dropout) self.enc4 = ConvBlock(f * 4, f * 8, dropout) self.pool = nn.MaxPool2d(2) # Bottleneck self.bottleneck = ConvBlock(f * 8, f * 16, dropout) # Decoder self.up4 = nn.ConvTranspose2d(f * 16, f * 8, kernel_size=2, stride=2) self.att4 = AttentionGate(x_ch=f * 8, g_ch=f * 8, inter_ch=f * 4) self.dec4 = ConvBlock(f * 16, f * 8, dropout) self.up3 = nn.ConvTranspose2d(f * 8, f * 4, kernel_size=2, stride=2) self.att3 = AttentionGate(x_ch=f * 4, g_ch=f * 4, inter_ch=f * 2) self.dec3 = ConvBlock(f * 8, f * 4, dropout) self.up2 = nn.ConvTranspose2d(f * 4, f * 2, kernel_size=2, stride=2) self.att2 = AttentionGate(x_ch=f * 2, g_ch=f * 2, inter_ch=f) self.dec2 = ConvBlock(f * 4, f * 2, dropout) self.up1 = nn.ConvTranspose2d(f * 2, f, kernel_size=2, stride=2) self.att1 = AttentionGate(x_ch=f, g_ch=f, inter_ch=max(f // 2, 1)) self.dec1 = ConvBlock(f * 2, f, dropout) # Output: 1-channel logits; apply sigmoid outside (or use BCEWithLogits). self.out_conv = nn.Conv2d(f, 1, kernel_size=1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) b = self.bottleneck(self.pool(e4)) u4 = self.up4(b) a4 = self.att4(e4, u4) d4 = self.dec4(torch.cat([u4, a4], dim=1)) u3 = self.up3(d4) a3 = self.att3(e3, u3) d3 = self.dec3(torch.cat([u3, a3], dim=1)) u2 = self.up2(d3) a2 = self.att2(e2, u2) d2 = self.dec2(torch.cat([u2, a2], dim=1)) u1 = self.up1(d2) a1 = self.att1(e1, u1) d1 = self.dec1(torch.cat([u1, a1], dim=1)) return self.out_conv(d1) # logits def dice_coefficient(probs: torch.Tensor, targets: torch.Tensor, smooth: float = 1e-6) -> torch.Tensor: """Per-batch Dice on binary probability maps. Returns scalar in [0,1].""" probs = probs.contiguous().view(probs.size(0), -1) targets = targets.contiguous().view(targets.size(0), -1) inter = (probs * targets).sum(dim=1) denom = probs.sum(dim=1) + targets.sum(dim=1) return ((2.0 * inter + smooth) / (denom + smooth)).mean() def iou_metric(probs: torch.Tensor, targets: torch.Tensor, smooth: float = 1e-6) -> torch.Tensor: probs = probs.contiguous().view(probs.size(0), -1) targets = targets.contiguous().view(targets.size(0), -1) inter = (probs * targets).sum(dim=1) union = probs.sum(dim=1) + targets.sum(dim=1) - inter return ((inter + smooth) / (union + smooth)).mean() def dice_loss(logits: torch.Tensor, targets: torch.Tensor, smooth: float = 1e-6) -> torch.Tensor: probs = torch.sigmoid(logits) return 1.0 - dice_coefficient(probs, targets, smooth) def combined_dice_bce_loss( logits: torch.Tensor, targets: torch.Tensor, dice_weight: float = 0.6, ) -> torch.Tensor: """Weighted Dice + BCE-with-logits, matching the TF combined_loss(0.6, 0.4).""" bce = F.binary_cross_entropy_with_logits(logits, targets) dl = dice_loss(logits, targets) return dice_weight * dl + (1.0 - dice_weight) * bce __all__ = [ 'AttentionUNet', 'ConvBlock', 'AttentionGate', 'dice_coefficient', 'iou_metric', 'dice_loss', 'combined_dice_bce_loss', ]