File size: 6,600 Bytes
3366f95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""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',
]