Implementação de Funções de Perda Multiescala e Perceptuais em PyTorch para GANs

Discriminador Multiescala (MultiScaleDiscriminator)

Em tarefas de síntese de imagens de alta resolução, um único discriminador frequentemente falha em avaliar simultaneamente a coerência estrutural global e a precisão das texturas locais. A arquitetura multiescala aborda essa limitação operando com múltiplas instâncias de um discriminador PatchGAN. Cada instância processa a imagem em uma resolução diferente, obtida através de operações sucessivas de downsampling (como o Average Pooling).

import torch
import torch.nn as nn
import numpy as np

class PatchGANDiscriminator(nn.Module):
    def __init__(self, in_channels, base_filters=64, num_layers=3, norm_layer=nn.BatchNorm2d, 
                 use_sigmoid=False, return_intermediate=False):
        super(PatchGANDiscriminator, self).__init__()
        self.return_intermediate = return_intermediate
        self.num_layers = num_layers

        kernel_size = 4
        padding = int(np.ceil((kernel_size - 1.0) / 2))
        
        # Bloco inicial
        layers = [[nn.Conv2d(in_channels, base_filters, kernel_size, stride=2, padding=padding), 
                   nn.LeakyReLU(0.2, inplace=True)]]

        current_filters = base_filters
        # Blocos intermediários
        for layer_idx in range(1, num_layers):
            prev_filters = current_filters
            current_filters = min(current_filters * 2, 512)
            layers.append([
                nn.Conv2d(prev_filters, current_filters, kernel_size, stride=2, padding=padding),
                norm_layer(current_filters), 
                nn.LeakyReLU(0.2, inplace=True)
            ])

        # Bloco penúltimo
        prev_filters = current_filters
        current_filters = min(current_filters * 2, 512)
        layers.append([
            nn.Conv2d(prev_filters, current_filters, kernel_size, stride=1, padding=padding),
            norm_layer(current_filters),
            nn.LeakyReLU(0.2, inplace=True)
        ])

        # Bloco de saída
        layers.append([nn.Conv2d(current_filters, 1, kernel_size, stride=1, padding=padding)])

        if use_sigmoid:
            layers.append([nn.Sigmoid()])

        if return_intermediate:
            for idx, layer_seq in enumerate(layers):
                setattr(self, f'block_{idx}', nn.Sequential(*layer_seq))
        else:
            flat_layers = [item for sublist in layers for item in sublist]
            self.main_model = nn.Sequential(*flat_layers)

    def forward(self, x):
        if self.return_intermediate:
            features = [x]
            for idx in range(self.num_layers + 2):
                block = getattr(self, f'block_{idx}')
                features.append(block(features[-1]))
            return features[1:]
        return self.main_model(x)


class MultiScaleDiscriminator(nn.Module):
    def __init__(self, in_channels, base_filters=64, num_layers=3, norm_layer=nn.BatchNorm2d, 
                 use_sigmoid=False, num_discriminators=3, return_intermediate=False):
        super(MultiScaleDiscriminator, self).__init__()
        self.num_discriminators = num_discriminators
        self.num_layers = num_layers
        self.return_intermediate = return_intermediate
     
        for i in range(num_discriminators):
            net_d = PatchGANDiscriminator(in_channels, base_filters, num_layers, norm_layer, use_sigmoid, return_intermediate)
            if return_intermediate:                                
                for j in range(num_layers + 2):
                    setattr(self, f'scale{i}_block{j}', getattr(net_d, f'block_{j}'))                                   
            else:
                setattr(self, f'scale{i}_model', net_d.main_model)
 
        self.downsample_pool = nn.AvgPool2d(3, stride=2, padding=[1, 1], count_include_pad=False)
 
    def process_single_scale(self, model_blocks, x):
        if self.return_intermediate:
            out = [x]
            for block in model_blocks:
                out.append(block(out[-1]))
            return out[1:]
        return [model_blocks(x)]
 
    def forward(self, x):        
        results = []
        x_down = x
        for i in range(self.num_discriminators):
            if self.return_intermediate:
                blocks = [getattr(self, f'scale{self.num_discriminators-1-i}_block{j}') for j in range(self.num_layers + 2)]
            else:
                blocks = getattr(self, f'scale{self.num_discriminators-1-i}_model')
            
            results.append(self.process_single_scale(blocks, x_down))
            if i != (self.num_discriminators - 1):
                x_down = self.downsample_pool(x_down)
        return results

Cálculo da Perda Adversarial

Para consolidar os resultados do discriminador multiescala, a função de custo adversarial itera sobre as saídas de todas as escalas. A implementação abaixo suporta a abordagem Least Squares GAN (LSGAN), que utiliza o Erro Quadrático Médio (MSE) para estabilizar o treinamento, ou a GAN clássica baseada em Entropia Cruzada Binária (BCE).

class AdversarialLoss(nn.Module):
    def __init__(self, use_lsgan=True, real_val=1.0, fake_val=0.0):
        super(AdversarialLoss, self).__init__()
        self.real_val = real_val
        self.fake_val = fake_val
        self.real_tensor = None
        self.fake_tensor = None
        self.loss_fn = nn.MSELoss() if use_lsgan else nn.BCELoss()

    def generate_target(self, prediction, is_real):
        if is_real:
            if self.real_tensor is None or self.real_tensor.numel() != prediction.numel():
                self.real_tensor = torch.full_like(prediction, self.real_val, requires_grad=False)
            return self.real_tensor
        else:
            if self.fake_tensor is None or self.fake_tensor.numel() != prediction.numel():
                self.fake_tensor = torch.full_like(prediction, self.fake_val, requires_grad=False)
            return self.fake_tensor

    def forward(self, predictions, is_real):
        total_loss = 0
        if isinstance(predictions[0], list):
            for pred_list in predictions:
                final_pred = pred_list[-1]
                target = self.generate_target(final_pred, is_real)
                total_loss += self.loss_fn(final_pred, target)
        else:            
            target = self.generate_target(predictions[-1], is_real)
            total_loss = self.loss_fn(predictions[-1], target)
        return total_loss

Perda Perceptual com VGG19

Funções de perda baseadas estritamente em pixels (como L1 ou L2) tendem a produzir imagens borradas, pois penalizam variações de alta frequência. A perda perceptual mitiga esse efeito ao calcular a distância entre os mapas de características extraídos por uma rede convolucional profunda pré-treinada, como a VGG19, garantindo que a semântica visual da imagem gerada corresponda à da imagem alvo.

from torchvision import models

class VGG19FeatureExtractor(nn.Module):
    def __init__(self, requires_grad=False):
        super(VGG19FeatureExtractor, self).__init__()
        vgg19 = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1)
        vgg_features = vgg19.features

        self.block1 = nn.Sequential(*[vgg_features[i] for i in range(2)])
        self.block2 = nn.Sequential(*[vgg_features[i] for i in range(2, 7)])
        self.block3 = nn.Sequential(*[vgg_features[i] for i in range(7, 12)])
        self.block4 = nn.Sequential(*[vgg_features[i] for i in range(12, 21)])
        self.block5 = nn.Sequential(*[vgg_features[i] for i in range(21, 30)])

        if not requires_grad:
            for param in self.parameters():
                param.requires_grad = False

    def forward(self, x):
        h1 = self.block1(x)
        h2 = self.block2(h1)        
        h3 = self.block3(h2)        
        h4 = self.block4(h3)        
        h5 = self.block5(h4)                
        return [h1, h2, h3, h4, h5]

class PerceptualLoss(nn.Module):
    def __init__(self, device='cuda'):
        super(PerceptualLoss, self).__init__()        
        self.vgg_extractor = VGG19FeatureExtractor().to(device)
        self.criterion = nn.L1Loss()
        # Ponderação empírica para cada bloco de extração
        self.layer_weights = [1.0/32, 1.0/16, 1.0/8, 1.0/4, 1.0]        

    def forward(self, generated_img, target_img):              
        gen_features = self.vgg_extractor(generated_img)
        target_features = self.vgg_extractor(target_img)
        
        loss = 0
        for i in range(len(gen_features)):
            loss += self.layer_weights[i] * self.criterion(gen_features[i], target_features[i].detach())        
        return loss

Perda de Variância de Gradiente

Para reforçar a nitidez das bordas e a riqueza das texturas, a perda de variância de gradiente analisa a distribuição estatística das derivadas espaciais da imagem. Ela aplica filtros de Sobel para isolar gradientes horizontais e vertiacis, e utiliza a operação unfold para dividir as imagens em patches não sobrepostos, calculando o erro entre as variâncias locais das imagens real e gerada.

import torch.nn.functional as F

class GradientVarianceLoss(nn.Module):
    def __init__(self, window_size):
        super(GradientVarianceLoss, self).__init__()
        self.window_size = window_size
        
        # Filtros de Sobel para extração de gradientes
        sobel_x = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=torch.float32).view(1, 1, 3, 3)
        sobel_y = torch.tensor([[1, 2, 1], [0, 0, 0], [-1, -2, -1]], dtype=torch.float32).view(1, 1, 3, 3)
        
        self.register_buffer('sobel_x', sobel_x)
        self.register_buffer('sobel_y', sobel_y)
        self.patch_extractor = nn.Unfold(kernel_size=(self.window_size, self.window_size), stride=self.window_size)

    def forward(self, pred, target):
        # Conversão para escala de cinza se a imagem possuir 3 canais (RGB)
        if pred.shape[1] == 3 and target.shape[1] == 3:
            pred_gray = 0.2989 * pred[:, 0:1] + 0.5870 * pred[:, 1:2] + 0.1140 * pred[:, 2:3]
            target_gray = 0.2989 * target[:, 0:1] + 0.5870 * target[:, 1:2] + 0.1140 * target[:, 2:3]
        elif pred.shape[1] == 1 and target.shape[1] == 1:
            pred_gray, target_gray = pred, target
        else:
            raise ValueError("Número de canais inválido. Esperado 1 ou 3.")

        # Aplicação dos filtros de convolução (Sobel)
        gx_target = F.conv2d(target_gray, self.sobel_x, stride=1, padding=1)
        gy_target = F.conv2d(target_gray, self.sobel_y, stride=1, padding=1)
        gx_pred = F.conv2d(pred_gray, self.sobel_x, stride=1, padding=1)
        gy_pred = F.conv2d(pred_gray, self.sobel_y, stride=1, padding=1)

        # Extração de patches não sobrepostos
        patches_gx_target = self.patch_extractor(gx_target)
        patches_gy_target = self.patch_extractor(gy_target)
        patches_gx_pred = self.patch_extractor(gx_pred)
        patches_gy_pred = self.patch_extractor(gy_pred)

        # Cálculo da variância estatística em cada patch
        var_target_x = torch.var(patches_gx_target, dim=1)
        var_pred_x = torch.var(patches_gx_pred, dim=1)
        var_target_y = torch.var(patches_gy_target, dim=1)
        var_pred_y = torch.var(patches_gy_pred, dim=1)

        # Erro quadrático médio entre as variâncias espaciais
        loss = F.mse_loss(var_target_x, var_pred_x) + F.mse_loss(var_target_y, var_pred_y)
        return loss

Tags: Pytorch computer-vision GAN vgg19 loss-functions

Publicado em 7-15 01:41