안녕하세요.

 

오늘은 저번 포스팅에서 paper review를 진행했던 Patch SVDD 방법론에 대한 code review를 진행해보려고 합니다.

 

기존 모델 architecture 위주로 리뷰했던 이전 글들과는 달리, 해당 방법론은 설명할 것들이 많고 복잡하여 조금은 다른 형태로 진행하게 될 것 같습니다.

 

먼저 큰 분류로는 1. Train / 2. Test로 나눠서 설명하고, Train과 Test에서도 전체적인 흐름을 먼저 제시하려고 합니다.

 

그러고 나서, 전체적인 흐름 중 한 단계씩을 세부적으로 설명하는 형식으로 진행해보려고 합니다.

 

따라서, 개괄적인 내용만 확인해보고 싶으시다면 1.1과 2.1만을 확인하셔도 되고 세부적으로 코드를 라인 바이 라인으로 확인하고 싶으시다면 전체 내용을 보시면 되겠습니다.

 

 

해당 논문의 paper review는 이전 포스팅에서 확인하실 수 있습니다.

 

https://cumulu-s.tistory.com/42

 

10. Patch SVDD: Patch-level SVDD for Anomaly Detection and Segmentation - paper review

안녕하세요. 굉장히 오랜만에 리뷰 글을 쓰는 것 같은데요. 어쩌다 보니 많은 일정들과 업무들이 몰려버리는 바람에, 2주 정도는 리뷰에 손댈 틈 없이 지냈습니다 ㅠㅠ 다음 주에도 일이 많긴 하

cumulu-s.tistory.com

 

그럼 코드 리뷰를 진행해보겠습니다.

 

 

 

1. Training

 

 

1.1에서는 학습이 전체적으로 어떻게 이루어지는지 글로 설명드리고, 1.2.x에서는 해당 내용을 코드 기준으로 설명해드립니다.

 

 

1.1 Training의 전체적인 흐름

 

 

가장 먼저 학습을 진행해야 하는 모델들을 정의해줍니다.

 

우리가 사용할 모델은 Encoder와 classifier가 있습니다.

 

 

다음으로는 학습을 하는 데 사용하는 데이터셋을 구축하게 됩니다.

 

데이터셋은 총 4개를 구축하게 되며, (1) 64x64 patch dataset (+pos), (2) 32x32 patch dataset (+pos), (3) 64x64 patch dataset (no pos), (4) 32x32 patch dataset (no pos)로 구성됩니다.

 

그리고 각 데이터셋은 항상 patch pair로 구성됩니다. 

 

기본적으로 학습이 이루어질 때 main patch와 이와 인접한 neighborhood patch로 1쌍이 구성되기 때문입니다.

 

pos는 position 정보를 약자로 표현한 것인데 논문에서 제시한 self-supervised learning을 진행할 때 classifier가 맞춰야 하는 ground-truth가 됩니다.

 

예를 들어서, 논문의 Fig. 4의 그림을 기준으로 생각해보자면 해당 케이스에서는 pos가 0이 될 것입니다.

 

 

데이터셋이 구성되었다면, 일반적인 딥러닝 학습과 동일하게 batch size를 기준으로 dataloader를 통해 데이터셋을 불러오게 됩니다.

 

4개의 데이터셋에 대해서 각각 학습해야 하는 것이 아니라, 4개의 데이터셋을 하나의 Concat Dataset으로 구성하여 사용해서 batch 단위로 4개의 데이터셋에 포함된 데이터들이 나오도록 구성합니다.

 

position 정보를 포함한 데이터셋을 이용해서 $\mathcal{L}_{SSL}$을 계산하고, position 정보가 포함되지 않은 데이터셋을 이용해서 $\mathcal{L}_{SVDD'}$를 계산합니다.

 

정확히 얘기하자면, position 정보를 포함한 데이터셋은 64x64 patch dataset과 32x32 patch dataset이 있으므로 $\mathcal{L}_{SSL}$은 $\mathcal{L}_{SSL64}$와 $\mathcal{L}_{SSL32}$의 합이 되는 것이죠.

 

그리고 position 정보를 포함하지 않은 데이터셋도 마찬가지로 64x64 patch dataset과 32x32 patch dataset이 있으므로 $\mathcal{L}_{SVDD'}$은 $\mathcal{L}_{SVDD'64}$와 $\mathcal{L}_{SVDD'32}$의 합이 되는 것입니다.

 

이를 이용해서 논문에서 나온 대로 $\mathcal{L}_{Patch SVDD} = \lambda\mathcal{L}_{SVDD'} + \mathcal{L}_{SSL}$로 계산해 최종 loss를 계산해줍니다.

 

그리고 이를 이용하여 Backpropagation을 진행해 classifier와 encoder를 학습시키게 됩니다.

 

Training 과정이 끝나게 되면, 최종적으로는 학습이 완료된 Encoder를 얻게 됩니다.

 

코드 상으로는 논문에 나와있는 것처럼 normal patch를 별도로 저장하지는 않습니다.

 

 

 

1.2.1 Encoder 및 classifier 정의

 

 

1.2.x 에서는 세부적인 코드를 포함해 설명을 진행합니다.

 

먼저 Encoder 및 classifier 정의를 수행하는 부분을 살펴봅니다.

 

# main_train.py (line32 - line41)
with task('Networks'):
        enc = EncoderHier(64, D).cuda()
        cls_64 = PositionClassifier(64, D).cuda()
        cls_32 = PositionClassifier(32, D).cuda()

        modules = [enc, cls_64, cls_32]
        params = [list(module.parameters()) for module in modules]
        params = reduce(lambda x, y: x + y, params)

        opt = torch.optim.Adam(params=params, lr=lr)

 

코드가 여러 가지. py 파일로 저장되어 있는 구조이므로, 찾기 쉽도록 주석을 이용하여 해당 코드가 포함된 파일의 위치와 line의 위치를 적어두겠습니다.

 

먼저 enc라는 변수로 Encoder를 정의하고, cls_64와 cls_32로 classifier를 정의합니다.

 

그리고 이를 modules로 합친 뒤, 각 모델에 있는 parameter를 params로 전부 합치고, reduce 함수를 통해서 모두 합쳐줍니다.

 

optimizer는 논문에 나온 대로 Adam optimizer를 사용하고 있는 모습입니다.

 

그렇다면 이제 Encoder와 classifier가 어떤 구조로 되어있는지 확인해봐야겠죠?

 

먼저 Encoder부터 살펴봅니다.

 

# networks.py line 132 - line 160
class EncoderHier(nn.Module):
    def __init__(self, K, D=64, bias=True):
        super().__init__()

        if K > 64:
            self.enc = EncoderHier(K // 2, D, bias=bias)

        elif K == 64:
            self.enc = EncoderDeep(K // 2, D, bias=bias)

        else:
            raise ValueError()

        self.conv1 = nn.Conv2d(D, 128, 2, 1, 0, bias=bias)
        self.conv2 = nn.Conv2d(128, D, 1, 1, 0, bias=bias)

        self.K = K
        self.D = D

    def forward(self, x):
        h = forward_hier(x, self.enc, K=self.K)

        h = self.conv1(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv2(h)
        h = torch.tanh(h)

        return h

 

모델은 크게 두 부분으로 되어 있습니다.

 

self.enc와 self.conv1 + self.conv2입니다. 

 

self.con1과 self.conv2는 너무 단순하니까 별도로 설명은 안 드려도 될 것 같고요.

 

self.enc를 보면 EncoderDeep으로 되어 있는 것을 알 수 있죠? 이걸 또 살펴봐야겠네요.

 

 

# networks.py line 75 - line 116
class EncoderDeep(nn.Module):
    def __init__(self, K, D=64, bias=True):
        super().__init__()

        self.conv1 = nn.Conv2d(3, 32, 3, 2, 0, bias=bias)
        self.conv2 = nn.Conv2d(32, 64, 3, 1, 0, bias=bias)
        self.conv3 = nn.Conv2d(64, 128, 3, 1, 0, bias=bias)
        self.conv4 = nn.Conv2d(128, 128, 3, 1, 0, bias=bias)
        self.conv5 = nn.Conv2d(128, 64, 3, 1, 0, bias=bias)
        self.conv6 = nn.Conv2d(64, 32, 3, 1, 0, bias=bias)
        self.conv7 = nn.Conv2d(32, 32, 3, 1, 0, bias=bias)
        self.conv8 = nn.Conv2d(32, D, 3, 1, 0, bias=bias)

        self.K = K
        self.D = D

    def forward(self, x):
        h = self.conv1(x)
        h = F.leaky_relu(h, 0.1)

        h = self.conv2(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv3(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv4(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv5(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv6(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv7(h)
        h = F.leaky_relu(h, 0.1)

        h = self.conv8(h)
        h = torch.tanh(h)

        return h

 

nn.Conv2d를 8개 쌓고, 중간에 activation function은 leaky_relu를 사용하였으며 마지막은 tanh를 사용한 구조가 되겠네요.

 

그렇다면, 사실상 Encoder는 EncoderDeep(conv 8개) + self.conv1 + self.conv2로 총 10개의 conv layer가 쌓인 형태라는 사실을 확인할 수 있겠습니다.

 

다음으로는 Classifier를 한번 살펴보겠습니다.

 

# networks.py line 214 - line 265
class PositionClassifier(nn.Module):
    def __init__(self, K, D, class_num=8):
        super().__init__()
        self.D = D

        self.fc1 = nn.Linear(D, 128)
        self.act1 = nn.LeakyReLU(0.1)

        self.fc2 = nn.Linear(128, 128)
        self.act2 = nn.LeakyReLU(0.1)

        self.fc3 = NormalizedLinear(128, class_num)

        self.K = K

    def save(self, name):
        fpath = self.fpath_from_name(name)
        makedirpath(fpath)
        torch.save(self.state_dict(), fpath)

    def load(self, name):
        fpath = self.fpath_from_name(name)
        self.load_state_dict(torch.load(fpath))

    def fpath_from_name(self, name):
        return f'ckpts/{name}/position_classifier_K{self.K}.pkl'

    @staticmethod
    def infer(c, enc, batch):
        x1s, x2s, ys = batch

        h1 = enc(x1s)
        h2 = enc(x2s)

        logits = c(h1, h2)
        loss = xent(logits, ys)
        return loss

    def forward(self, h1, h2):
        h1 = h1.view(-1, self.D)
        h2 = h2.view(-1, self.D)

        h = h1 - h2

        h = self.fc1(h)
        h = self.act1(h)

        h = self.fc2(h)
        h = self.act2(h)

        h = self.fc3(h)
        return h

 

함수들이 여러 개 있기는 하지만, 결국은 fully connected layer 3개로 이루어진 구조네요.

 

 

1.2.2 데이터 셋 불러오기

 

 

다음 단계는 데이터셋을 불러오게 됩니다.

 

코드상에서는 다음과 같습니다.

 

# main_train.py line 57 - line 59
with task('Datasets'):
        train_x = mvtecad.get_x_standardized(obj, mode='train')
        train_x = NHWC2NCHW(train_x)

 

mvtecad의 get_x_standardized를 확인하면 될 것 같습니다.

 

 

# mvtecad.py line 70 - line 73
def get_x_standardized(obj, mode='train'):
    x = get_x(obj, mode=mode)
    mean = get_mean(obj)
    return (x.astype(np.float32) - mean) / 255

 

보니까 get_x라는 함수를 또 살펴봐야 할 것 같네요.

 

get_mean도 살펴봐야 합니다.

 

# mvtecad.py line 48 - line 67
def get_x(obj, mode='train'):
    fpattern = os.path.join(DATASET_PATH, f'{obj}/{mode}/*/*.png')
    fpaths = sorted(glob(fpattern))

    if mode == 'test':
        fpaths1 = list(filter(lambda fpath: os.path.basename(os.path.dirname(fpath)) != 'good', fpaths))
        fpaths2 = list(filter(lambda fpath: os.path.basename(os.path.dirname(fpath)) == 'good', fpaths))

        images1 = np.asarray(list(map(imread, fpaths1)))
        images2 = np.asarray(list(map(imread, fpaths2)))
        images = np.concatenate([images1, images2])

    else:
        images = np.asarray(list(map(imread, fpaths)))

    if images.shape[-1] != 3:
        images = gray2rgb(images)
    images = list(map(resize, images))
    images = np.asarray(images)
    return images
    

# mvtecad.py line 104 - line 107
def get_mean(obj):
    images = get_x(obj, mode='train')
    mean = images.astype(np.float32).mean(axis=0)
    return mean
   

 

get_x는 데이터셋을 불러오는 함수입니다.

 

fpattern으로 obj의 mode에 해당하는 모든 데이터의 경로를 가져오고, 이를 sorted 해서 fpaths에 저장합니다.

 

그리고 imread 함수를 이용해서 fpaths에 있는 이미지 데이터들을 다 가져와 list로 만든 다음 이를 numpy array로 변경합니다.

 

imread는 imageio라는 패키지에 있는 함수로, 이미지를 불러오는 함수입니다.

 

그러고 나서 resize라는 함수를 적용해서 이미지를 resizing 시켜줍니다. 

 

# mvtecad.py line 20 - line 21
def resize(image, shape=(256, 256)):
    return np.array(Image.fromarray(image).resize(shape[::-1]))

 

resize는 이미지를 256 x 256의 형태로 바꿔주는 역할을 합니다.

 

따라서, get_x를 통해서 training image를 256 x 256으로 resize 시켜서 가져오고, get_mean을 통해서 평균을 구하고, 이를 get_x_standardized 함수에서 사용하여 이미지에서 평균을 빼고 255로 나누어 기본적인 전처리를 해줍니다.

 

255로 나눈다는 것은 0 ~ 255의 value space를 가지도록 만드는 게 아니라, 0 ~ 1의 value space를 가지도록 만들어준다는 것을 의미한다고 보시면 되겠습니다.

 

그리고 NHWC2NCHW 함수는 이름만 봐도 알겠지만 데이터의 차원을 변경해주는 함수가 되겠습니다.

 

즉 batch / channel / height / width 순으로 값을 변경해줍니다.

 

 

 

1.2.3 Dataset 구성하기

 

 

 

다음 단계는 데이터셋을 구성하게 됩니다.

 

# main_train.py line 62 - line 70 
        datasets = dict()
        datasets[f'pos_64'] = PositionDataset(train_x, K=64, repeat=rep)
        datasets[f'pos_32'] = PositionDataset(train_x, K=32, repeat=rep)
        
        datasets[f'svdd_64'] = SVDD_Dataset(train_x, K=64, repeat=rep)
        datasets[f'svdd_32'] = SVDD_Dataset(train_x, K=32, repeat=rep)

        dataset = DictionaryConcatDataset(datasets)
        loader = DataLoader(dataset, batch_size=256, shuffle=True, num_workers=2, pin_memory=True)

 

 

데이터셋은 총 4개가 있는데요.

 

PositionDataset은 position 정보가 포함된 dataset입니다.

 

PositionDataset부터 보도록 하겠습니다.

 

 

# dataset.py line 117 - line 153
class PositionDataset(Dataset):
    def __init__(self, x, K=64, repeat=1):
        super(PositionDataset, self).__init__()
        self.x = np.asarray(x)
        self.K = K
        self.repeat = repeat

    def __len__(self):
        N = self.x.shape[0]
        return N * self.repeat

    def __getitem__(self, idx):
        N = self.x.shape[0]
        K = self.K
        n = idx % N

        image = self.x[n]
        p1, p2, pos = generate_coords_position(256, 256, K)

        patch1 = crop_image_CHW(image, p1, K).copy()
        patch2 = crop_image_CHW(image, p2, K).copy()

        # perturb RGB
        rgbshift1 = np.random.normal(scale=0.02, size=(3, 1, 1))
        rgbshift2 = np.random.normal(scale=0.02, size=(3, 1, 1))

        patch1 += rgbshift1
        patch2 += rgbshift2

        # additive noise
        noise1 = np.random.normal(scale=0.02, size=(3, K, K))
        noise2 = np.random.normal(scale=0.02, size=(3, K, K))

        patch1 += noise1
        patch2 += noise2

        return patch1, patch2, pos

Dataset 객체는 __init__과 __len__, __getitem__으로 이루어지게 되는데요.

 

__init__은 데이터셋을 구성할 때 필요한 요소들이라고 생각해주시면 좋고요.

 

__len__는 말 그대로 데이터셋의 크기를 나타냅니다. 

 

Patch SVDD에서는 repeat이라는 parameter를 입력받게 되는데, 이는 1장당 패치를 몇 개 뽑을지를 결정한다고 보시면 되겠습니다.

 

따라서 __len__의 return이 N * self.repeat이 되는 것이죠.

 

__getitem__쪽도 보게 되면, N = self.x.shape[0] 이므로 train_x의 데이터셋 크기가 되고 n = idx % N 이므로 data의 index를 N으로 나눈 결과라고 보시면 되겠습니다.

 

다음으로 p1, p2, pos = generate_coords_position(256, 256, K)로 만들어지는 것을 보실 수 있는데요.

 

이는 다음 코드를 통해서 얻게 됩니다.

 

# datasets.py line 9 - line 40
def generate_coords(H, W, K):
    h = np.random.randint(0, H - K + 1)
    w = np.random.randint(0, W - K + 1)
    return h, w

pos_to_diff = {
    0: (-1, -1),
    1: (-1, 0),
    2: (-1, 1),
    3: (0, -1),
    4: (0, 1),
    5: (1, -1),
    6: (1, 0),
    7: (1, 1)
}

def generate_coords_position(H, W, K):
    with task('P1'):
        p1 = generate_coords(H, W, K)
        h1, w1 = p1

    pos = np.random.randint(8)

    with task('P2'):
        J = K // 4

        K3_4 = 3 * K // 4
        h_dir, w_dir = pos_to_diff[pos]
        h_del, w_del = np.random.randint(J, size=2)

        h_diff = h_dir * (h_del + K3_4)
        w_diff = w_dir * (w_del + K3_4)

        h2 = h1 + h_diff
        w2 = w1 + w_diff

        h2 = np.clip(h2, 0, H - K)
        w2 = np.clip(w2, 0, W - K)

        p2 = (h2, w2)

    return p1, p2, pos

 

generate_coords의 경우는 H와 W, K를 입력으로 받고 랜덤으로 좌표를 만들어주게 됩니다.

 

예를 들어서, 이미지가 256 x 256이고 우리가 생각하는 패치의 사이즈가 64x64라면 

 

가로와 세로의 좌표가 0부터 192 사이에서 뽑혀야 가로 세로로 64만큼 뽑을 수 있겠죠?

 

그 좌표를 뽑아준다고 보시면 되겠습니다.

 

다음으로는 generate_coords_position인데요.

 

위 함수와 동일하게 H, W, K를 받게 되고요. 

 

generate_coords를 통해서 뽑은 h와 w를 각각 h1, w1로 저장합니다.

 

그리고 랜덤으로 0부터 7까지의 숫자를 뽑아서 이를 pos로 저장하게 됩니다.

 

다음으로는 J = K // 4로 지정하고, K3_4 = 3 * K // 4로 지정한 다음 

 

h_dir, w_dir로 곱해줄 상수를 결정합니다. 기준이 되는 patch를 기준으로 3x3 grid를 생각했을 때 하나를 결정하는 것이죠.

 

그리고 h_del, w_del로 2차원으로 0부터 J-1까지의 숫자 중에 랜덤 하게 뽑아주게 됩니다.

 

h_diff는 h_dir(3x3 grid 기준 위치)와 h_del + K3_4를 곱해줘서 결정하고, 이를 아까 앞에서 뽑아둔 h1과 w1에 더해줍니다.

 

그리고 np.clip을 통해서 만약 H-K나 W-K를 넘어가는 경우 clipping 해줍니다.

 

이는 뽑히는 위치에 따라서 patch를 실제로 뽑을 수 있는 좌표를 넘어가는 경우들이 있기 때문이죠.

 

마지막으로 결정된 p1(main patch의 시작점)와 p2(neighbor patch의 시작점), 그리고 neighbor patch의 위치인 pos를 결괏값으로 냅니다.

 

p1, p2가 결정되었으니, 이를 시작점으로 해서 실제 patch를 잘라내야겠죠?

 

그 작업이 crop_image_CHW 함수를 이용해서 이루어지게 됩니다.

 

# utils.py line 83 - line 85
def crop_image_CHW(image, coord, K):
    h, w = coord
    return image[:, h: h + K, w: w + K]

 

매우 간단한 함수로 짜져 있는데요.

 

channel은 그대로 사용하고, 시작점인 h와 w를 기준으로 K만큼 이동한 부분만 뽑아내 줍니다.

 

main patch와 neighbor patch를 각각 뽑아서 patch1과 patch2로 저장합니다.

 

그리고 논문에서 언급했던 self-supervised learning 과정에서 shortcuts을 학습하는 것을 방지하고자 RGB에 랜덤 하게 값을 추가해주는 작업이 이루어지고, 추가적으로 noise를 추가해줍니다.

 

이 작업을 통해서 main patch와 neighbor patch, position의 쌍을 구성하게 됩니다.

 

뽑히는 patch는 다음과 같은 느낌으로 뽑히게 됩니다.

 

 

위의 이미지는 제가 opencv를 이용하여 실제 뽑히는 patch를 MVTec AD의 한 데이터에 직접 적용해서 시각화를 한 자료입니다.

 

빨간색이 main patch이고, 검은색이 neighbor patch라고 보시면 될 것 같습니다.

 

 

 

다음으로는 SVDD_Dataset인데, 이 부분은 거의 사실상 동일하니까 넘어가겠습니다.

 

DictionaryConcatDataset이라는 함수로 4개의 데이터셋을 합쳐주게 되는데요.

 

해당 코드를 보게 되면 데이터가 4개의 key와 이에 해당하는 데이터로 data가 나오는 것을 확인할 수 있습니다.

 

# utils.py line 44 - line 59
class DictionaryConcatDataset(Dataset):
    def __init__(self, d_of_datasets):
        self.d_of_datasets = d_of_datasets
        lengths = [len(d) for d in d_of_datasets.values()]
        self._length = min(lengths)
        self.keys = self.d_of_datasets.keys()
        assert min(lengths) == max(lengths), 'Length of the datasets should be the same'

    def __getitem__(self, idx):
        return {
            key: self.d_of_datasets[key][idx]
            for key in self.keys
        }

    def __len__(self):
        return self._length

 

다음으로는 DataLoader인데 이는 많이 사용되는 코드니 별도로 설명은 생략하겠습니다.

 

 

 

1.2.4 Loss 계산 및 학습 진행

 

 

DataLoader까지 만들었으니, 이제는 DataLoader를 이용해서 데이터를 batch 단위로 뽑아낼 수 있게 됩니다.

 

이를 통해서 각 batch 단위로 loss를 계산하고, 학습을 진행하게 됩니다.

 

# main_train.py line 55 - line 72
for i_epoch in range(args.epochs):
        if i_epoch != 0:
            for module in modules:
                module.train()

            for d in loader:
                d = to_device(d, 'cuda', non_blocking=True)
                opt.zero_grad()

                loss_pos_64 = PositionClassifier.infer(cls_64, enc, d['pos_64'])
                loss_pos_32 = PositionClassifier.infer(cls_32, enc.enc, d['pos_32'])
                loss_svdd_64 = SVDD_Dataset.infer(enc, d['svdd_64'])
                loss_svdd_32 = SVDD_Dataset.infer(enc.enc, d['svdd_32'])

                loss = loss_pos_64 + loss_pos_32 + args.lambda_value * (loss_svdd_64 + loss_svdd_32)

                loss.backward()
                opt.step()

 

d가 바로 데이터가 되는 것이고요.

 

먼저 loss_pos_64부터 계산하는 과정을 살펴보겠습니다.

 

위에서 PositionClassifier를 소개해드렸는데, 이번에는 infer 부분만 보면 되겠습니다.

 

# networks.py line 242 - line 250
def infer(c, enc, batch):
        x1s, x2s, ys = batch

        h1 = enc(x1s)
        h2 = enc(x2s)

        logits = c(h1, h2)
        loss = xent(logits, ys)
        return loss
        
xent = nn.CrossEntropyLoss()

 

infer에서는 classifier와 encoder, batch를 input으로 받게 됩니다.

 

loss_pos_64 = PositionClassifier.infer(cls_64, enc, d['pos_64'])이므로 64차원 patch를 받는 classifier인 cls_64와 encoder인 enc, 그리고 데이터셋 중에 64x64 짜리 position 정보를 포함하는 batch를 받게 됩니다.

 

그리고 batch를 x1s, x2s, ys로 분리하고(x1s이 main patch, x2s가 neighbor patch, ys가 position 정보라고 생각하시면 됩니다.) x1s와 x2s를 각각 encoder에 통과시킨 뒤, 이 둘의 결과를 classifier에 통과시켜서 logit을 얻습니다.

 

logit과 ys, 즉 position 정보를 기준으로 cross-entropy loss를 계산해서 return 합니다.

 

 

loss_pos_32도 사실상 동일한 과정을 통해 얻게 되므로, 생략합니다.

 

다음으로는 loss_svdd_64를 보겠습니다.

 

이는 SVDD_Dataset.infer를 이용하게 되는데요.

 

# datasets.py line 106 - line 114
@staticmethod
    def infer(enc, batch):
        x1s, x2s, = batch
        h1s = enc(x1s)
        h2s = enc(x2s)
        diff = h1s - h2s
        l2 = diff.norm(dim=1)
        loss = l2.mean()

        return loss

 

사실상 앞에 구한 loss랑 비슷하게 흘러가는 것을 보실 수 있겠죠?

 

마지막으로 loss는 loss_pos_64 + loss_pos_32 + lambda * (loss_svdd_64 + loss_svdd_32)로 계산됩니다.

 

이 부분은 논문에 나와있는 내용 그대로 구현이 된 부분이고요.

 

이를 통해 backward()를 진행하고, optimizer가 step을 하게 됩니다.

 

마지막으로 매 epoch마다 enc.save(obj)를 통해서 encoder를 저장해주게 됩니다.(main_train.py line 76)

 

여기까지 train과 관련된 내용을 살펴봤습니다.

 

어째 좀 자세하게 다룬다 싶으면 글 분량이 엄청 많아지는 기분인데요.

 

다음으로는 evaluate 부분을 다뤄보겠습니다.

 

 

 

2. Test

 

 

해당 Section에서는 학습이 완료된 encoder를 가지고 test를 진행하는 과정을 설명합니다.

 

이 과정을 통해서 anomaly detection & anomaly segmentation의 AUROC 및 anomaly map을 얻게 됩니다.

 

 

 

2.1 Test의 전체적인 흐름

 

 

학습이 완료된 Encoder를 이용해서, training data와 test data를 64차원, 32차원으로 각각 embedding 시켜줍니다.

 

이를 통해서 64 dim training embedding, 32 dim training embedding, 64 dim test embedding, 32 dim test embedding을 얻게 됩니다.

 

그리고 64 dim training embedding과 64 dim test embedding을 이용해서 embedding space 기준으로 test embedding과 train embedding 간 L2 distance가 가장 가까운 데이터들을 모으게 됩니다.

 

동일한 과정을 32 dim에 대해서도 시행하고요.

 

논문에서 nearest normal patch라고 쓰여있던 부분이 바로 이 부분이라고 보시면 되겠습니다.

 

이 과정을 거치게 되면 64 dim test embedding과 가장 가까운 training embedding들을 모두 가지게 되고, 이를 이용해서 anomaly map을 만들어줍니다.

 

마찬가지로 32 dim의 경우도 동일한 과정을 거칩니다.

 

64 anomaly map과 32 anomaly map을 구했다면, 이를 element-wise addition과 element-wise multiplication을 거쳐 최종적으로 64, 32, mult, sum anomaly map을 얻게 됩니다.

 

 

 

2.2.1 embedding 만들기

 

 

첫 번째로 다뤄볼 부분은 training data와 test data를 encoder를 활용하여 embedding space로 mapping 하는 것입니다.

 

# insepction.py line 38 - line 43
def eval_encoder_NN_multiK(enc, obj):
    x_tr = mvtecad.get_x_standardized(obj, mode='train')
    x_te = mvtecad.get_x_standardized(obj, mode='test')

    embs64_tr = infer(x_tr, enc, K=64, S=16)
    print(embs64_tr.shape)
    embs64_te = infer(x_te, enc, K=64, S=16)
    print(embs64_te.shape)

    x_tr = mvtecad.get_x_standardized(obj, mode='train')
    x_te = mvtecad.get_x_standardized(obj, mode='test')

    embs32_tr = infer(x_tr, enc.enc, K=32, S=4)
    embs32_te = infer(x_te, enc.enc, K=32, S=4)

    embs64 = embs64_tr, embs64_te
    embs32 = embs32_tr, embs32_te

    return eval_embeddings_NN_multiK(obj, embs64, embs32)

 

먼저 데이터를 x_tr와 x_te로 가져오고, 학습된 encoder를 통해서 inference를 진행합니다.

 

infer는 다음과 같습니다.

 

# inspection.py line 11 - line 25
def infer(x, enc, K, S):
    x = NHWC2NCHW(x)
    dataset = PatchDataset_NCHW(x, K=K, S=S)
    loader = DataLoader(dataset, batch_size=64, shuffle=False, pin_memory=True)
    embs = np.empty((dataset.N, dataset.row_num, dataset.col_num, enc.D), dtype=np.float32)  # [-1, I, J, D]
    enc = enc.eval()
    with torch.no_grad():
        for xs, ns, iis, js in loader:
            xs = xs.cuda()
            embedding = enc(xs)
            embedding = embedding.detach().cpu().numpy()

            for embed, n, i, j in zip(embedding, ns, iis, js):
                embs[n, i, j] = np.squeeze(embed)
    return embs

 

먼저 데이터를 batch / channel / height / width 형태로 바꿔줍니다.

 

그리고 이를 이용해서 PatchDataset을 만들어줍니다.

 

# utils.py line 88 - line 127
class PatchDataset_NCHW(Dataset):
    def __init__(self, memmap, tfs=None, K=32, S=1):
        super().__init__()
        self.arr = memmap
        self.tfs = tfs
        self.S = S
        self.K = K
        self.N = self.arr.shape[0]

    def __len__(self):
        return self.N * self.row_num * self.col_num

    @property
    def row_num(self):
        N, C, H, W = self.arr.shape
        K = self.K
        S = self.S
        I = cnn_output_size(H, K=K, S=S)
        return I

    @property
    def col_num(self):
        N, C, H, W = self.arr.shape
        K = self.K
        S = self.S
        J = cnn_output_size(W, K=K, S=S)
        return J

    def __getitem__(self, idx):
        N = self.N
        n, i, j = np.unravel_index(idx, (N, self.row_num, self.col_num))
        K = self.K
        S = self.S
        image = self.arr[n]
        patch = crop_CHW(image, i, j, K, S)

        if self.tfs:
            patch = self.tfs(patch)

        return patch, n, i, j

PatchDataset의 경우는 K(patch의 사이즈)와 S(Stride)를 input으로 받아서 만들어지게 됩니다.

 

아까 말씀드렸지만, Dataset 객체의 경우는 __len__ 와 __getitem__을 봐야만 어떤 작업이 이루어지는지 파악할 수 있습니다.

 

먼저 __len__은 self.N * self.row_num * self.col_num이므로 각각이 뭔지 파악해야 합니다.

 

self.N은 self.arr.shape[0]이며 self.arr는 memmap이므로 이는 데이터셋의 크기를 의미한다는 것을 파악할 수 있습니다.

 

예를 들어서, bottle class의 training set은 (209, 3, 256, 256)인데, 여기서 self.N은 209가 됩니다.

 

다음으로 self.row_num을 봐야 하는데, 이는 Stride와 K를 기준으로 row가 몇 개 나올지를 나타내게 됩니다.

 

예를 들어서, 256 x 256 짜리의 이미지에서 64 x 64 짜리 patch를 stride 16을 적용해서 뽑으면 row와 col이 얼마나 나올지를 나타낸다는 것이죠.

 

(256 - 64) / 16을 하면 12이므로 총 row와 col은 13개가 나옵니다.

 

따라서 이를 기반으로 training set이 (209, 3, 256, 256)인 경우 K=64, S=16를 적용 시 Dataset의 크기는 209 * 13 * 13개가 됩니다.

 

다음으로 __getitem__을 살펴보면 np.unravel_index를 통해서 n, i, j를 만드는 모습을 볼 수 있는데요.

 

np.unravel_index는 처음 보시는 분들도 계시겠지만, 어떤 크기의 데이터에서 원하는 번째의 좌표를 뽑을 수 있게 됩니다.

 

예시로, (209, 13, 13)에서 12번째 좌표를 뽑는다면, np.unravel_index(12, (209, 13, 13))이 되고 (0, 0, 12)가 output으로 나오게 됩니다.

 

이를 통해서 데이터셋 전체 중에서 i번째의 좌표를 계속해서 뽑아낼 수 있으며, 이를 이용해서 patch를 뽑아낼 수 있게 되는 것이죠.

 

image = self.arr[n]를 이용해서 n번째 데이터를 들고 오고, crop_CHW 함수를 이용해서 i, j 좌표에서 K=64 S=16를 적용해서 원하는 위치의 image patch를 뽑게 됩니다.

 

예를 들어서, (i, j) = (1, 2)이고 K=64, S=16라고 한다면 crop의 시작점은 (16, 32)가 될 것이고, 이를 기준으로 가로 세로로 64씩 만큼 image patch를 뽑을 수 있게 됩니다.

 

그리고 patch, n, i, j를 데이터셋의 output으로 만들어주게 되죠.

 

 

다음으로는 다시 infer 함수로 돌아가 보겠습니다.

 

embs는 저희가 결과로 저장할 embedding을 나타내고, embs의 shape는 (-1, row, col, D) 형태가 됩니다.

 

만약 데이터셋이 (209, 3, 256, 256)이고 K=64, S=16, encoder Dim = 64을 적용한다면 결과로 나오게 되는 embedding은 (209, 13, 13, 64)가 됩니다.

 

 

다음으로는 for문을 살펴보겠습니다.

 

for xs, ns, iis, js in loader: 부분인데요.

 

아까 PatchDataset에서 patch, n, i, j를 output으로 낸다는 얘기를 했었습니다.

 

즉, xs는 patch가 되며, 이 xs를 encoder에 투입하여 embedding을 얻게 됩니다. 64차원 embedding을 얻는 것이니, 이 embedding은 (Batch size, 64, 1, 1)의 shape를 가집니다. 두 번째 64는 차원을 나타내는 것이라고 보시면 되겠습니다.

 

그리고 이를 다시 for embed, n, i, j를 이용해서 각 위치에 64차원 embedding을 투입하게 됩니다.

 

위의 예시를 이용해서 생각해보자면 n, i, j는 (0, 0, 0)부터 (209, 13, 13)까지 총 209 * 13 * 13개가 나오게 될 것이고, 각각에 64차원짜리 embedding을 채워 넣어서 최종적으로는 (209, 13, 13, 64) 짜리 embedding을 완성하게 됩니다.

 

동일한 방법으로 test set에 대해서도 진행하여 test embedding을 얻게 됩니다.

 

32차원의 train embedding과 test embedding도 과정은 동일하니 설명은 넘어가도록 하겠습니다.

 

 

2.2.2 Nearest normal patch 찾기

 

 

2.2.1에서 64차원의 training embedding, 64차원의 test embedding, 32차원의 training embedding, 32차원의 test embedding을 만들었습니다.

 

논문에서 언급되었듯이, 이제는 embedding space에서 test embedding을 기준으로 가장 가까운 normal patch를 찾는 작업을 진행하게 됩니다.

 

# insepction.py line 63 - line 97
def eval_embeddings_NN_multiK(obj, embs64, embs32, NN=1):
    emb_tr, emb_te = embs64
    maps_64 = measure_emb_NN(emb_te, emb_tr, method='kdt', NN=NN)
    maps_64 = distribute_scores(maps_64, (256, 256), K=64, S=16)
    det_64, seg_64 = assess_anomaly_maps(obj, maps_64)

    emb_tr, emb_te = embs32
    maps_32 = measure_emb_NN(emb_te, emb_tr, method='ngt', NN=NN)
    maps_32 = distribute_scores(maps_32, (256, 256), K=32, S=4)
    det_32, seg_32 = assess_anomaly_maps(obj, maps_32)

    maps_sum = maps_64 + maps_32
    det_sum, seg_sum = assess_anomaly_maps(obj, maps_sum)

    maps_mult = maps_64 * maps_32
    det_mult, seg_mult = assess_anomaly_maps(obj, maps_mult)

    return {
        'det_64': det_64,
        'seg_64': seg_64,

        'det_32': det_32,
        'seg_32': seg_32,

        'det_sum': det_sum,
        'seg_sum': seg_sum,

        'det_mult': det_mult,
        'seg_mult': seg_mult,

        'maps_64': maps_64,
        'maps_32': maps_32,
        'maps_sum': maps_sum,
        'maps_mult': maps_mult,
    }

 

작업은 모두 동일하게 이루어지니, 64차원 embedding을 기준으로 과정을 설명해보겠습니다.

 

먼저, embs64에서 emb_tr와 emb_te를 분리합니다.

 

각각은 64차원의 training embedding과 test embedding을 나타냅니다.

 

이를 가지고 measure_emb_NN라는 함수를 적용하여 nearest normal patch를 찾아주게 됩니다.

 

# inspection.py line 102 - line 110
def measure_emb_NN(emb_te, emb_tr, method='kdt', NN=1):
    from .nearest_neighbor import search_NN
    D = emb_tr.shape[-1]
    train_emb_all = emb_tr.reshape(-1, D)

    l2_maps, _ = search_NN(emb_te, train_emb_all, method=method, NN=NN)
    anomaly_maps = np.mean(l2_maps, axis=-1)

    return anomaly_maps

 

measure_emb_NN는 training embedding과 test embedding을 input으로 받고 nearest normal patch를 연산해주는 함수입니다.

 

먼저 train_emb_all이라는 변수로 training embedding을 2차원으로 reshape 해주게 됩니다.

 

예를 들어서 bottle class의 경우, 64차원 training embedding은 (209, 13, 13, 64)이므로, 이를 (209 * 13 * 13, 64)로 바꿔주는 것입니다.

 

그러고 나서 search_NN라는 함수에 test embedding과 2차원으로 reshape 된 training embedding을 input으로 투입합니다.

 

# nearest_neighbor.py line 9 - line 28
def search_NN(test_emb, train_emb_flat, NN=1, method='kdt'):
    if method == 'ngt':
        return search_NN_ngt(test_emb, train_emb_flat, NN=NN)

    from sklearn.neighbors import KDTree
    kdt = KDTree(train_emb_flat)

    Ntest, I, J, D = test_emb.shape
    closest_inds = np.empty((Ntest, I, J, NN), dtype=np.int32)
    l2_maps = np.empty((Ntest, I, J, NN), dtype=np.float32)

    for n in range(Ntest):
        for i in range(I):
            dists, inds = kdt.query(test_emb[n, i, :, :], return_distance=True, k=NN)
            closest_inds[n, i, :, :] = inds[:, :]
            l2_maps[n, i, :, :] = dists[:, :]

    return l2_maps, closest_inds

 

아까 만들어놓은 2차원 training embedding을 가지고 KDTree를 만들어주고요.

 

가장 가까운 train embedding의 index를 저장할 closest_inds와 l2 distance를 저장할 l2_maps를 변수로 저장하고 Ntest, I, J, NN의 shape로 만들어줍니다.

 

NN은 몇 개의 Nearest normal patch를 찾을지 인데, 우리의 경우에서는 가장 가까운 patch 한 개만 찾으므로, 이는 1로 설정됩니다.

 

Nest, I, J, D는 test_emb의 shape가 되는데, bottle class의 경우 (83, 13, 13, 64)이므로 각각이 83, 13, 13, 64로 설정됩니다.

 

다음으로는 for문을 살펴보아야 하는데, 먼저 Ntest에 대해서 for문을 적용합니다. 즉, 이는 test embedding 각각에 대해서 for문을 돈다고 보시면 됩니다.

 

그리고 for i in range(I)인데, I는 두 번째 차원의 값입니다. 

 

다음으로는 kdt.query를 test_emb[n, i, :, :]를 이용해서 작동시킵니다.

 

kdt.query는 KDTree를 이용해서 가장 가까운 요소를 찾아주는 코드라고 생각하시면 됩니다.

 

그리고 test_emb[n, i, :, :]라면 앞의 for문의 n과 i를 이용해서 첫 번째 차원과 두 번째 차원은 골라주고, 나머지 세 번째 차원과 네 번째 차원은 그대로 가져온다는 것을 의미합니다.

 

예를 들어서, bottle class의 경우 (83, 13, 13, 64)인데, 만약 n =0, i =0이라면 [13, 64] 차원의 데이터가 되겠죠.

 

이를 이용해서 kdt.query를 적용하게 되면, 가장 가까운 요소가 찾아지고, 이는 [13, 1]의 데이터가 됩니다.

(64차원짜리가 13개가 있는데, 13개 각각이 training embedding 중에서 가장 가까운 데이터를 찾는다 정도로 생각하시면 됩니다. 13개는 딥러닝에서 배치 단위로 데이터를 연산하는 것처럼 한 번에 연산하게 되는 단위? 정도로 보면 되겠네요.)

 

즉 이는 아까 위에서 언급했던 training embedding을 2차원으로 변경한 (209 * 13 * 13, 64)에서 가장 가까운 요소를 찾은 것이다 라고 보시면 되겠습니다.

 

이를 closest_inds와 l2_maps에 계속해서 저장해줍니다.

 

최종적으로 for문 2개를 모두 연산하게 되면, l2_maps와 closest_inds가 만들어지게 되고, 이를 return 해줍니다.

 

 

다시 measure_emb_NN로 돌아가면, l2_maps와 closest_inds 중에서 l2_maps만 저장을 하고, 이를 np.mean 해줍니다. 

 

axis = -1인 것을 확인할 수 있는데, 왜냐하면 search_NN을 통해서 얻게 되는 결과물이 (Ntest, I, J, 1)의 shape를 가지기 때문입니다.

 

이 4차원 데이터를 3차원으로 바꿔주는 역할 정도로 볼 수 있겠네요.

 

이걸 통해서 anomaly_map을 최종적으로 얻게 됩니다.

 

 

bottle class를 기준으로 생각해보면, (209, 13, 13, 64) 짜리 64차원 training embedding과 (83, 13, 13, 64) 짜리 64차원 test embedding을 input으로 집어넣어서 test embedding 기준으로 embedding space에서 가장 가까운 training embedding과의 l2 distance를 다 찾은 뒤, (83, 13, 13) 짜리의 anomaly map을 만들었다! 정도로 이해하면 딱 깔끔한 것 같습니다.

 

 

다음으로 살펴볼 내용은 이 anomaly map을 이용해서 실제 논문에서 구현된 anomaly map을 만드는 과정입니다.

 

논문에서 나온 anomaly map은 이미지의 사이즈와 동일한 사이즈였죠?

 

그래서 이 (83, 13, 13) 짜리를 (83, 256, 256) 짜리로 만드는 작업이 필요합니다.

 

이 작업은 distribute_scores라는 함수를 통해서 구현됩니다.

 

# utils.py line 155 - line 158
def distribute_scores(score_masks, output_shape, K: int, S: int) -> np.ndarray:
    N = score_masks.shape[0]
    results = [distribute_score(score_masks[n], output_shape, K, S) for n in range(N)]
    return np.asarray(results)

 

score_masks는 구해놓은 (83, 13, 13)라고 생각하시면 되고, output_shape는 (256, 256)로 지정하게 됩니다.

 

K는 64이며, S는 16입니다. 이는 기존에 학습을 진행할 때 정해둔 값을 그대로 전달하면 됩니다.

 

코드를 보니, distribute_score라는 함수를 적용해서 list로 만든 다음 이를 numpy array로 return 하는 코드네요.

 

그럼 distribute_score를 봐야겠습니다.

 

 # utils.py line 161 - line 176
def distribute_score(score_mask, output_shape, K: int, S: int) -> np.ndarray:
    H, W = output_shape
    mask = np.zeros([H, W], dtype=np.float32)
    cnt = np.zeros([H, W], dtype=np.int32)

    I, J = score_mask.shape[:2]
    for i in range(I):
        for j in range(J):
            h, w = i * S, j * S

            mask[h: h + K, w: w + K] += score_mask[i, j]
            cnt[h: h + K, w: w + K] += 1

    cnt[cnt == 0] = 1

    return mask / cnt

 

위의 distribute_scores 함수에서, distribute_score(score_masks[n])을 하는 것을 확인할 수 있는데요.

 

즉 test embedding 한 개에 대해서 distribute_score 함수를 적용하는 것을 확인할 수 있습니다.

 

bottle class의 경우 (83, 13, 13)이었는데, 여기서 (13, 13)만 빼서 distribute_score에 투입시킨다는 의미가 되겠죠?

 

먼저 mask와 cnt라는 변수를 지정해서 (256, 256) shape로 만들어줍니다.

 

그리고 I, J를 score_mask의 shape 중에서 첫 번째와 두 번째 요소로 저장해줍니다.

 

bottle class에서 I = 13, J = 13가 됩니다.

 

for i in range(I)와 for j in range(J)를 통해서 I = 0, J = 0부터 I = 0, J = 12, I = 12, J = 12까지 반복문을 도는 것을 확인할 수 있습니다.

 

h, w를 i * S와 J * S로 저장하고요. 

 

예를 들어서 I = 0, J = 0이면 h = 0, w = 0이 됩니다.

 

그리고 mask[h: h+K, w: w+K] += score_mask[i, j]의 작업을 수행하게 되는데, 이는 score_mask의 [i, j]의 요소를 K만큼 분배해주는 것을 의미합니다. 

 

이는 이전에 Encoding을 진행할 때, K 크기의 patch를 S 만큼의 stride를 적용해서 Encoding 했던 것을 다시 원상복구 해주는 것이라고 이해해볼 수 있습니다.

 

그리고 cnt는 작업이 진행될 때마다 +1 씩 해주는데, 이는 이미지의 영역 중에서 score_mask가 여러 번 더해지는 구간이 있기 때문입니다.

 

예를 들어서, mask의 [0:16, 0:16]은 score_mask[0, 0]에 의해서 단 한번 distribute 받게 되지만, 다른 구간은 두 번 이상씩 distribute를 받을 수 있습니다. 

 

따라서 이를 counting 해주고, 마지막에 return 과정에서 distribute 받은 개수만큼 나눠주는 작업을 수행하게 됩니다.

 

만약 이미지 전체를 돌았는데도 cnt가 0인 경우가 있다면 이는 1로 지정해줍니다. return mask / cnt이기 때문에 만약 cnt가 0이 되어버리면 ZeroDivisionError가 발생하기 때문이겠죠?

 

 

최종적으로 정리해보면, distribute_scores 함수를 통해서 test embedding 각각에 대해 Encoding 당시 지정한 K와 S값을 가지고서 test embedding을 test anomaly map으로 만들어주는 과정을 진행하게 됩니다.

 

test embedding (83, 13, 13) => test anomaly map(83, 256, 256)으로 변환이 되게 됩니다.

 

 

 

이를 통해서 assess_anomaly_maps 함수를 거치게 됩니다.

 

# inspection.py line 32 - line 37
def assess_anomaly_maps(obj, anomaly_maps):
    auroc_seg = mvtecad.segmentation_auroc(obj, anomaly_maps)

    anomaly_scores = anomaly_maps.max(axis=-1).max(axis=-1)
    auroc_det = mvtecad.detection_auroc(obj, anomaly_scores)
    return auroc_det, auroc_seg

 

assess_anomaly_maps 함수는 우리가 방금 얻은 anomaly_map을 가지고 segmentation auroc와 detection auroc를 계산하는 함수가 되겠습니다.

 

detection auroc를 계산할 때는 우리가 구한 anomaly map 중에서 가장 큰 값을 anomaly score로 계산합니다.

(.max(axis=-1).max(axis=-1)를 참고해보시면 됩니다. 이렇게 하면 각 이미지 당 가장 높은 값이 나오게 됩니다.)

 

 

 

위 과정을 32차원 embedding에 대해서도 동일하게 적용하고, 64차원 anomaly map과 32차원 anomaly map을 element-wise addition과 element-wise multiplication을 해주면 최종적으로 모든 결과가 도출되게 됩니다.

 

 

여기까지 Training과 Inference에 대한 부분을 대략적으로 살펴보았습니다.

 

엄청 디테일하게 모든 코드를 다루기에는 코드의 양이 굉장히 많아 조금 어려울법한 내용들을 위주로 조금 디테일하게 설명하였고, 나머지는 대부분 이미 구현된 library를 쓰거나 했기 때문에 조금만 찾아봐도 내용이 나올 것이라고 생각합니다.

 

 

 

3. 실제 코드를 구동하면서 주의해야 할 점

 

 

마지막으로는 제가 Patch SVDD 모델을 사용하면서 주의해야 할 점이라고 생각하는 부분입니다.

 

 

(1) Dataset의 양이 많아지면 / 데이터의 크기가 크면 연산량이 기하급수적으로 늘어납니다.

 

 

저는 제가 연구하고 있는 Custom dataset을 가지고 있는데, training 데이터가 3000장 이상, train 데이터가 약 1000장 정도 가지고 있습니다. 

 

이미지의 크기 또한 해당 모델에서 사용한 256x256보다 훨씬 큰 상황입니다.

 

따라서 inference를 한 번 시행하는데만 거의 10시간 가까이 소요되므로, 기존 main_train.py에 코드가 짜져 있는 대로 1 epoch 당 AUROC 성능 체크를 진행하게 되면 1 epoch 당 10시간씩 소요되게 됩니다.

 

그래서 제가 실제로 모델을 사용할 때는 1 epoch 당 AUROC 성능 체크하는 코드를 제외했습니다.

 

그리고 기존 코드에서 구현되어 있지 않았던 epoch 당 loss graph를 만들어주는 코드를 추가하여 loss가 정상적으로 우하향하는지를 점검하는 방식으로 변경하였습니다.

 

데이터셋의 양이 많아지면 연산량이 기하급수적으로 늘어나는 이유는, inference 시 Nearest normal patch를 찾아줘야 하는데 이미지의 사이즈가 커지면 당연히 embedding vector의 크기도 커지고 데이터의 양이 많아지면 전체적인 장수가 많아지므로 search 해줘야 하는 대상인 test embedding도 늘어나고 search 할 대상인 train embedding도 늘어나면서 전체적으로 연산량이 증가하게 됩니다.

 

 

(2) Dataset마다 최적의 lambda를 찾아줘야 합니다.

 

 

Dataset마다 최적의 lambda가 다릅니다.

 

논문에서도 언급되었지만, 데이터셋에 있는 이미지들의 특성에 따라서 lambda가 커야 좋을 수도 있고 작아야 좋을 수도 있기 때문이죠.

 

따라서 자신이 사용하는 Dataset에 적합한 lambda가 얼마일지는 실험을 통해서 찾아야만 합니다.

 

제 경우에서는 lambda를 올리면 성능이 떨어지게 되어,  0.001을 사용하고 있습니다.

 

저자의 Github에는 이와 관련된 Issue들이 올라온 상태이며, 저자가 추가적으로 설명해주고 있으니 이를 참고해보시면 좋을 듯합니다.

 

https://github.com/nuclearboy95/Anomaly-Detection-PatchSVDD-PyTorch/issues/10

 

Performances of some classes drop when training more epochs · Issue #10 · nuclearboy95/Anomaly-Detection-PatchSVDD-PyTorch

I don't quite understand why the performances of Carpet, Grid, Tile, and Screw classes getting worse and worse during training. The best performances of them almost all appear in the first epoc...

github.com

 

그리고 얘기하는 내용을 보면 MVTec AD dataset에 대해서 각 class 별로 최적의 hyperparameter가 있어 보이는데, 별도로 저자가 공개하지는 않았습니다.

 

그 대신, 각 class에 대해서 학습이 완료된 weight를 공개하고 있어 이를 통해 논문에 나온 성능을 복원할 수 있는 상황입니다.

 

 

 

여기까지 Patch SVDD의 code review를 마무리 지어보려고 합니다.

 

워낙 내용이 많고 코드도 많다 보니까 글이 많이 길어진 감이 있네요.

 

그래도 저 또한 글을 작성하면서 다시 한번 내용을 조금 더 디테일하게 점검해볼 수 있는 계기가 되었네요.

 

다음 글에서는 또 새로운 논문을 가지고 찾아오겠습니다.

 

해당 논문에 대한 코드는 제 Github에서 확인하실 수 있습니다.

 

https://github.com/PeterKim1/paper_code_review/tree/master/10.%20Patch%20SVDD

 

PeterKim1/paper_code_review

paper review with codes. Contribute to PeterKim1/paper_code_review development by creating an account on GitHub.

github.com

 

감사합니다.

 

 

 

안녕하세요.

 

 

굉장히 오랜만에 리뷰 글을 쓰는 것 같은데요.

 

 

어쩌다 보니 많은 일정들과 업무들이 몰려버리는 바람에, 2주 정도는 리뷰에 손댈 틈 없이 지냈습니다 ㅠㅠ

 

 

다음 주에도 일이 많긴 하지만, 대략 정리가 모두 끝난 상태여서 주말에 남는 시간을 이용해 리뷰를 진행하려고 합니다.

 

 

이전에는 꽤 유명한 모델들을 위주로 리뷰를 했었다면, 이번에는 제가 메인으로 연구를 진행하고 있는 분야인 이미지에서의 이상 탐지 모델을 다뤄보려고 합니다.

 

 

논문의 주소는 다음과 같습니다.

 

 

https://arxiv.org/abs/2006.16067

 

Patch SVDD: Patch-level SVDD for Anomaly Detection and Segmentation

In this paper, we address the problem of image anomaly detection and segmentation. Anomaly detection involves making a binary decision as to whether an input image contains an anomaly, and anomaly segmentation aims to locate the anomaly on the pixel level.

arxiv.org

 

논문에서 제시하는 모델에 대한 설명은 Section 3에 나오므로, 모델 부분만 살펴보실 분들은 Section 3로 가주시면 되겠습니다.

 

 

그럼 시작해보겠습니다.

 

 

Abstract

 

 

이번 논문에서, 저자들은 image anomaly detection 및 segmentation 문제를 다룹니다.

 

Anomaly detection은 input image가 이상을 포함하고 있는지 아닌지를 결정하는 문제이고, anomaly segmentation은 pixel level에서 anomaly가 어디에 위치해 있는지 파악하는 것을 목표로 합니다.

 

Support vector data description (SVDD)는 anomaly detection을 위해서 오랫동안 사용되어온 알고리즘인데요, 본 논문에서 저자들은 이 알고리즘을 self-supervised learning을 사용하는 patch 기반의 딥러닝 방법론으로 확장하게 됩니다.

 

이러한 확장을 통해서 MVtec AD dataset에서 AUROC 기준으로 기존 SOTA method 대비 anomaly detection은 9.8%, anomaly segmentation은 7.0%의 성능 향상을 확인할 수 있었습니다.

 

 

 

1. Introduction

 

 

Anomaly detection은 input이 anomaly를 포함하고 있는지 아닌지를 결정하는 문제입니다.

 

이상을 탐지하는 것은 제조업이나 금융 산업에서 많이 마주할 수 있는 중요하고도 오래된 문제라고 할 수 있습니다.

(제조업에서는 제품을 생산하게 되면 생산된 제품이 양품인지 불량품인지를 판단해야 한다는 관점에서 이상 탐지가 필요하고, 금융 산업에서는 부정 거래 탐지 같은 것들이 되겠습니다. 예를 들어서, 한 달에 50만 원씩 쓰던 어떤 사용자가 갑자기 1000만 원짜리 제품을 구매하면 카드사에서 연락이 오게 됩니다. 일반적인 거래 패턴과는 다른 거래이기 때문에, anomaly로 탐지가 되는 것이죠.)

 

Anomaly detection은 보통 one-class classification으로 표현이 되는데, 이는 학습 도중에 분포를 모델링하는 데 있어서 비정상 샘플에 대한 접근이 불가능하거나 혹은 매우 불충분하기 때문입니다.

 

이미지 데이터에 대해서 생각해보면, 검출된 anomaly는 이미지 내 어떤 구역에 위치하게 되고 anomaly segmentation은 pixel level에서 anomaly의 위치를 찾아내는 문제입니다. 

 

본 논문에서는 image anomaly detection과 anomaly segmentation 문제를 다룹니다.

 

 

One-class support vector machine (OC-SVM)과 support vector data description (SVDD)는 one-class classification에 사용되는 고전적인 알고리즘인데요. 

 

OC-SVM은 kernel function이 주어졌을 때 원점으로부터 margin을 최대로 하는 hyperplane을 찾게 됩니다.

 

이와 유사하게, SVDD는 kernel space에서 데이터를 둘러싸는 hypersphere를 찾게 됩니다.

 

추후에, Ruff et al.은 SVDD를 딥러닝을 통해서 구현한 Deep SVDD를 제안하였고 이는 kernel function을 별도로 정해줘야 하는 기존의 방법론과는 달리 neural network를 통해서 kernel function을 대체하였습니다.

 

 

본 논문에서 저자들은 Deep SVDD를 patch-wise detection method로 확장하는 Patch SVDD를 제안합니다.

 

Patch SVDD는 상대적으로 높은 patch의 클래스 내 변동에도 강건하고, self-supervised learning에 의해서 더욱 강건해지게 됩니다.

 

Fig. 1은 제안된 방법론을 사용하여 anomaly의 위치를 파악하는 예시를 보여주고 있습니다.

 

 

 

 

 

 

2. Background

 

2.1 Anomaly detection and segmentation

 

 

Problem formulation

 

 

Anomaly detection은 input이 anomaly인지 아닌지를 결정하는 문제입니다. 

 

Anomaly의 정의는 작은 결함에서부터 out-of-distribution image까지를 포함하게 되는데요.

(out-of-distribution image라고 한다면, 고양이 이미지만 가득한 dataset에 개 이미지만 있다고 생각해주시면 쉬울 것 같습니다.)

 

저자들은 전자에 해당하는 image 내에서의 결함을 찾는 것에 초점을 두고 있습니다.

 

일반적인 detection method는 input의 abnormality를 측정하는 scoring function $A_\theta$를 학습하는 과정을 포함하게 되는데요. 

 

Test를 진행할 때는, 학습된 scoring function을 사용하여 높은 $A_\theta(x)$를 가진 input이 anomaly로 결정되게 됩니다.

 

scoring function에 대한 사실상 표준적인 지표는 Eq. 1로 표현되는 area under the receiver operating characteristic curve (AUROC)입니다.

 

Eq. 1

 

따라서, 좋은 scoring function은 normal data에 낮은 anomaly score를 매기고, abnormal data에 높은 anomaly score를 매길 수 있어야 합니다.

 

Anomaly segmentation도 유사하게 표현될 수 있는데, 각 pixel에 대한 anomaly score를 만들고(즉, anomaly map) 각 pixel에 대한 AUROC를 측정하는 것입니다.

 

 

Autoencoder-based methods

 

 

초기의 anomaly detection에 대한 deep learning approach는 autoencoder를 사용하는 것이었습니다.

 

이러한 autoencoder는 normal training data에만 학습되고, abnormal image에 대해서는 정확한 복원을 할 수 없게 됩니다.

 

따라서, input과 복원된 결과와의 차이가 abnormality를 나타내게 됩니다.

 

이후에 structural similarity indices, adversarial training, negative mining, iterative projection 등을 활용하는 연구들이 진행되었습니다.

 

과거 연구들 중 일부는 autoencoder의 학습된 latent feature를 anomaly detection에 활용하기도 했는데요.

 

Akcay et al. 은 latent feature의 reconstruction loss를 anomaly score로 정의하였고, Yarlagadda et al. 은 latent feature를 활용해 OC-SVM을 학습시키기도 했습니다.

 

더 최근에는, reconstruction loss보다도 restoration loss나 attention map과 같은 다른 요소들을 활용한 방법론들이 제안되었습니다.

 

 

Classifier-based methods

 

 

Golan et al의 연구 이후에, discriminative approach가 anomaly detection에 제안되어 왔습니다.

 

이러한 방법론들은 비정상 input image에 대해서 classifier가 confidence를 잃게 되는 현상을 사용하였습니다.

(예를 들어서, 어떤 classifier가 개, 고양이, 곰을 분류한다고 했을 때 개나 고양이, 곰의 이미지가 들어가게 되면 80%나 90% 이상으로 매우 높게 예측을 하게 됩니다. 하지만, 갑자기 새 이미지를 주고 예측을 하게 만들면 어쨌든 개, 고양이, 곰 중 하나로 예측을 하긴 하겠지만 80%나 90%처럼 높게 예측하지 않고 40%나 50%처럼 낮은 확률로 어떠한 class로 예측하게 됩니다.)

 

Unlabeled dataset이 주어졌을 때, classifier는 가상의 label을 예측하고자 학습하게 됩니다.

 

예를 들어서, Golan et al에서는 image를 random flip, rotate, translate 시킨 다음, classifier가 수행된 transformation의 유형을 예측하도록 만들게 됩니다.

 

만약 classifier가 confident 하고 correct 한 prediction을 만들 수 없다면, input image를 abnormal로 예측하게 됩니다.

 

Wang et al. 은 이러한 접근법이 training data 또한 anomaly를 일부 포함하는 unsupervised 상황에도 확장될 수 있음을 증명하였습니다.

 

Bergman et al.은 open-set classification method를 채택하여 non-image data를 포함시키고자 해당 방법론을 일반화시켰습니다. 

 

 

SVDD-based methods

 

 

SVDD는 고전적인 one-class classification 알고리즘입니다.

 

이는 모든 normal training data를 predefined kernel space로 mapping 시키고, kernel space에 존재하는 모든 데이터를 감싸는 가장 작은 hypersphere를 찾게 됩니다.

 

anomaly는 학습된 hypersphere 바깥쪽에 위치할 것으로 예상할 수 있습니다.

 

kernel function이 kernel space를 결정하므로, 학습 과정에서는 hypersphere의 반지름과 중심을 주로 결정하게 됩니다.

 

Ruff et al. 은 이러한 접근법을 deep neural network를 활용하여 향상시켰습니다.

 

이들은 kernel function을 대체하고자 neural network를 채택하였고 hypersphere의 반지름을 가지고 학습시켰습니다.

 

이러한 변형을 통해 encoder는 data-dependent transformation을 학습할 수 있게 되었고, high-dimensional and structured data에서 detection 성능을 향상시키게 되었습니다.

 

Trivial solution(즉, encoder가 상수를 내뱉는 상황)을 피하고자, network에서 bias term은 제거합니다.

 

 

 

2.2 Self-supervised representation learning

 

 

이미지에서 representation을 학습하는 것은 computer vision에서 중요한 문제이며, image의 representation을 annotation 없이 학습할 수 있는 여러 방법론들이 제안되어 왔는데요.

 

여러 연구분야 중 하나는  synthetic learning signal을 제공하기 위한 self-labeled task인 pretext task를 가지고 학습하는 encoder를 training 시키는 것입니다.

 

network가 pretext task를 잘 풀도록 학습된다면, network는 의미 있는 feature를 추출할 수 있을 것이라고 기대할 수 있겠죠.

 

pretext task로는 relative patch location을 예측하는 것, jigsaw puzzle를 푸는 것, colorizing images, counting objects, prediction rotations 등이 있습니다. (뒤에서 보시면 아시겠지만, 해당 논문은 relative patch location을 예측하는 것을 이용하여 self-supervised representation learning을 수행하게 됩니다.)

 

 

 

3. Methods

 

 

해당 Section에서는 본격적으로 본 논문에서 제시하는 방법론에 대해서 설명합니다.

 

 

3.1 Patch-wise Deep SVDD

 

 

Deep SVDD는 전체 training data를 feature space에서 작은 hypersphere 안으로 들어오도록 mapping 시키는 encoder를 학습하게 되는데요.

 

Encoder인 $f_\theta$는 feature와 hypersphere의 중심과의 Euclidean distance를 최소화하는 방향으로 학습이 됩니다.

 

따라서, loss function은 다음과 같습니다.

 

Eq. 2

 

$x$는 input image를 나타내고요.

 

Test를 진행할 때는 input의 representation과 center 사이의 거리가 anomaly score가 됩니다.

(앞에서 언급했었지만, 데이터를 가장 작은 hypersphere 안으로 들어가게끔 학습하므로, input의 representation이 hypersphere의 중심과 거리가 크다는 것은 hypersphere의 바깥쪽에 위치한다는 것임을 알 수 있겠죠.)

 

center $c$는 Eq. 3의 방법을 통해서 학습 이전에 계산됩니다. $N$는 training data의 수를 나타내고요.

 

Eq. 3

 

따라서, 학습을 통해서 feature들을 하나의 중심 주변으로 밀게 됩니다.

 

 

본 논문에서 저자들은 Deep SVDD를 patch 단위로 확장시키게 됩니다. Encoder는 Fig. 2에서 보이는 것처럼 이미지 전체를 encoding 하는 게 아니라, patch를 encoding 하도록 변경합니다.

 

Fig. 2

 

이에 따라서, inspection이 각 patch에 대해서 수행되게 됩니다. 

 

Patch-wise inspection은 여러 가지 장점을 가지고 있는데, 첫 번째로 inspection이 각 position에 대해 수행할 수 있으므로 해당 방법론을 사용했을 때 defect의 위치를 찾을 수 있게 되며,  두 번째로, 이러한 작업이 전반적으로 detection 성능을 향상시키게 됩니다.

 

 

Deep SVDD를 patch-wise inspection으로 확장하는 것은 직관적인데요.

 

Patch encoder $f_\theta$는 $L_{SVDD}$를 사용해서 학습되고, 이미지였던 $x$는 patch인 $p$로 대체됩니다.

 

Anomaly score는 이에 따라서 정의될 수 있고, Fig. 3에서 예시를 확인할 수 있습니다.

 

Fig. 3

 

기존의 SVDD loss를 사용하게 되면 높은 복잡도를 가지는 image에 대해서는 detection performance가 좋지 않은 것을 확인할 수 있습니다.

 

이는 patch가 높은 클래스 내 variation을 가지고 있기 때문인데요. 즉, 몇몇 패치들은 background에 대응되지만 다른 패치들은 object를 포함하고 있기 때문입니다.

 

그 결과로, 유사하지 않은 patch들의 모든 feature들을 하나의 center로 mapping 하게 되고, uni-modal cluster를 만들게 되어 representation과 content 간 connection을 약화시키게 됩니다.

 

그러므로 하나의 중심인 $c$를 사용하는 것은 적절하지 않다는 것을 알 수 있습니다.

 

그렇지만, center의 적절한 수를 결정하고 patch들을 각 center에 할당하는 것은 다루기가 힘든 문제입니다.

 

 

 

위의 문제를 우회하고자, 저자들은 center를 명시적으로 정의하고 patch를 할당하는 작업을 수행하지 않습니다.

 

그 대신에, 의미론적으로 유사한 patch들이 저절로 모이도록 encoder를 학습시키게 됩니다.

 

의미론적으로 유사한 패치는 공간적으로 인접한 patch를 sampling 해서 얻을 수 있으며, encoder는 다음 loss function을 사용하여 feature 간 distance를 최소화하도록 학습됩니다.

 

 

$p_i'$는 $p_i$와 인접한 patch를 나타냅니다.

 

추가적으로, representation이 patch의 semantics를 포착하도록 강제하기 위해, self-supervised learning도 수행합니다.

 

 

 

3.2 Self-supervised learning

 

 

Doersch et al. 은 Fig. 4에 나타난 것처럼 두 patch의 상대적 위치를 예측하기 위해 encoder와 classifier를 학습시켰습니다.

 

 

Encoder & classifier pair가 잘 학습되면 encoder는 location prediction에 사용되는 유용한 feature를 추출할 수 있게 됩니다.

 

이러한 특성은 특정 task에만 국한되는 것이 아니며, 이전 연구들은 self-supervised encoder가 다음 task를 위한 강력한 feature extractor 임을 언급하였습니다.

 

 

 

임의로 샘플링된 patch $p_1$에 대해서, Doersch et al. 은 3x3 grid 내 있는 8개의 이웃 patch 중 하나인 patch $p_2$를 샘플링하였습니다.

 

true relative position을 $y \in {0, ..., 7}$이라 하면, classifier $C_\phi$는 $y = C_\phi(f_\theta(p_1), f_\theta(p_2))$를 예측하도록 학습됩니다.

 

patch의 크기는 encoder의 receptive field와 동일합니다.

 

Classifier가 shortcuts을 활용하는 것을 방지하고자(예를 들어, color aberration), 저자들은 patch의 RGB channel을 임의로 perturb 하였습니다. 

 

 

논문에서는 별도로 shortcuts이 무엇인지 언급되지 않았으나, 인용한 논문을 찾아보니 다음과 같은 내용을 찾을 수 있었습니다.

 

Shortcuts have been described as “trivial solutions” to the pretext task that must be avoided to “ensure that the task forces the network to extract the desired information” 

 

In other words, shortcuts are easily learnable features that are predictive of the pretext label, and allow the network to stop learning once found.

 

위 내용에 따르면, Shortcuts은 소위 trivial solution(자명한 해)이며 이를 찾으면 network가 학습을 멈추게 만든다고 합니다. 

 

즉, RGB channel을 별도로 perturb 하는 것은 어떻게 보면 useful feature를 찾아낼 수 있도록 하는 역할을 하는 것 같습니다.

 

 

Doersch et al. 의 접근법을 따라서, 다음과 같은 loss term을 추가하여 self-supervised learning signal을 추가합니다.

 

 

그 결과로, encoder는 Eq. 6에서 볼 수 있듯이 scaling hyperparameter $\lambda$를 이용한 두 loss의 combination을 사용해 학습이 이루어지게 됩니다.

 

최적화는 SGD와 Adam optimizer를 이용해 이루어집니다.

 

Eq. 6

 

 

3.3 Hierarchical encoding

 

 

anomaly의 크기가 다양하기 때문에, 다양한 receptive field를 가지는 여러 개의 encoders를 사용하는 것은 크기의 변화에 대응할 수 있도록 도움을 줄 수 있습니다.

 

Section 4.2에서 다룰 실험 결과는 encoder에서 hierarchical structure를 사용하는 것이 anomaly detection 성능에도 도움을 준다는 것을 나타냅니다.

 

따라서, 저자들은 작은 encoder를 포함하는 hierarchical encoder를 사용하였습니다.

 

hierarchical encoder는 다음과 같이 정의될 수 있습니다.

 

 

Fig. 5에서 볼 수 있듯이 input patch $p$는 2 x 2 grid로 분할되고 이들의 feature는 $p$의 feature를 구성하기 위해 합쳐지게 됩니다.

 

receptive field size $K$를 가진 각 encoder는 patch size $K$의 self-supervised task를 이용해 학습이 이루어지게 됩니다.

 

실험이 진행되는 동안, large encoder와 small encoder의 receptive field는 각각 64, 32로 정해집니다.

 

 

 

3.4 Generating anomaly maps

 

 

Encoders를 학습한 후에, encoder로부터 얻게 되는 representation은 anomaly를 탐지할 때 사용됩니다.

 

첫 번째로, 모든 normal train patch의 representation {$f_\theta(p_{normal}) | p_{normal}$}은 계산되고 저장됩니다.

 

Query image $x$가 주어졌을 때, $x$에서 stride $S$를 이용해서 얻어지는 모든 patch $p$에 대해 feature space에서 가장 가까운 normal patch까지의 L2 distance는 anomaly score로 정의됩니다. (Eq. 8)

 

 

nearest neighbor search의 computational cost를 완화시키고자, 저자들은 approximate algorithm은 NGT를 채택해 사용하였습니다.

 

그 결과로, MVTec AD의 이미지 한 개에 대한 inspection은 대략 0.48초 정도 걸리게 됩니다.

 

Patch-wise로 계산된 anomaly score는 pixel로 분배가 되는데요.

 

그 결과로, 각 pixel들은 자신이 속한 모든 patch의 average anomaly score를 받게 되며 그 결과로 얻게 되는 anomaly map을 $\mathcal{M}$으로 표기합니다.

 

Section 3.3에서 논의한 multiple encoder는 multiple feature space를 구성하게 되며, 이에 따라 multiple anomaly map을 만들어냅니다.

 

저자들은 multiple anomaly map을 element-wise multiplication을 사용해 최종 anomaly map $\mathcal{M}_{multi}$를 얻게 되며 이는 anomaly segmentation 문제에 대한 answer가 됩니다.

 

 

$\mathcal{M}_{multi}$와 $\mathcal{M}_{big}$은 각각 $f_{small}$과 $f_{big}$을 사용해서 만들어지는 anomaly map입니다.

 

$\mathcal{M}_{multi}$에서 높은 anomaly score를 가지는 pixel이 defect을 포함하고 있다고 판단됩니다.

 

 

anomaly detection 문제를 해결하는 것은 간단한데요. 

 

Eq. 10으로 표현된 것처럼, 이미지 내에서 가장 높은 anomaly score를 가지는 pixel이 해당 이미지의 anomaly score가 됩니다.

 

Eq. 10

 

 

Fig. 6은 제안된 방법론의 전체적인 흐름을 나타내고, pseudo-code는 Appendix A1에서 제공되고 있습니다.

 

Fig. 6

 

Appendix A1(train)

 

 

Appendix A1(test)

 

논문에는 해당 Pseudo-code가 제시되어 있는데, 실제 코드 구현과는 다른 내용이 있어 실제로 해당 논문을 사용하실 것이라면 반드시 코드 내용을 살펴보셔야 할 것 같습니다.

 

다음 포스팅인 code review에서는 실제 공개된 코드를 기반으로 위에 제시된 Pseudo-code를 수정해볼 예정입니다.

 

 

4. Results and Discussion

 

 

제안된 방법론의 유효성을 검증하고자, 저자들은 MVTec AD dataset에 이를 적용하였습니다.

 

MVTec dataset은 15개 class의 산업 이미지로 구성되어 있고, 각 class는 object 혹은 texture로 분류됩니다.

 

10개의 object class는 규칙적으로 배치된 object를 포함하고 있으며, 반면에 texture class는 반복적인 패턴을 포함하고 있습니다.

 

 

4.1 Anomaly detection and segmentation results

 

 

Fig. 7은 제안된 방법론을 사용하여 만들어진 anomaly map을 보여주고 있으며, defect의 크기에 상관없이 적절하게 찾아내는 것을 나타냅니다.

 

Fig. 7

 

Table 1은 AUROC 기준으로 SOTA baseline과 비교했을 때 MVTec AD dataset에서의 detection and segmentation 성능을 보여줍니다.

 

 

Patch SVDD는 autoencoder-based 방법론과 classifier-based 방법론을 포함하는 powerful baseline에 비해서 SOTA 성능을 내고 있으며, Deep SVDD에 비해 55.6% 향상을 보여주고 있습니다.

 

 

4.2 Detailed analysis

 

 

t-SNE visualization

 

 

Fig. 8은 여러 train image의 학습된 feature의 t-SNE visualization을 보여줍니다.

 

 

Fig. 8(b)에 보이는 points에 위치한 patch는 Fig. 8(a)과 Fig. 8(c)에 있는 동일한 색과 동일한 크기를 가지는 point에 mapping 됩니다.

 

Fig. 8(a)에서, 유사한 색과 사이즈를 가진 points는 feature space에서 cluster를 형성하고 있습니다.

 

cable class에 있는 이미지들이 규칙적으로 배치되어 있으므로, 다른 이미지에서 나왔더라도 같은 위치에서 나온 patch는 유사한 content를 가지고 있습니다.

 

유사하게, 규칙적으로 배치된 object classes의 경우, t-SNE visualization에서 유사한 색과 크기를 가진 points는 semantically similar라고 생각될 수 있습니다.

 

대조적으로, Fig. 8(c)에 나타난 leather class의 feature는 반대 경향을 보입니다.

 

이는 texture class에 있는 patches들이 이미지 내에서의 위치와는 상관없이 유사하기 때문입니다.

 

즉, texture image에 대해서는 patch의 위치가 이들의 semantics와는 전혀 관련이 없습니다.

 

 

Effect of self-supervised learning

 

 

Patch SVDD는 $\mathcal{L}_{SVDD'}$와 $\mathcal{L}_{SSL}$이라는 두 개의 loss를 사용해서 encoder를 학습시키는데요.

 

제안된 loss term의 역할을 비교하기 위해서, 저자들은 ablation study를 수행했습니다.

 

Table 2는 $\mathcal{L}_{SVDD}$를 $\mathcal{L}_{SVDD'}$로 변경한 것과 $\mathcal{L}_{SSL}$을 도입한 것이 anomaly detection과 segmentation 성능을 향상시킨다는 것을 제안하고 있습니다.

 

 

Fig. 9는 제안된 loss term의 효과가 classes마다 다르다는 것을 보여줍니다.

 

 

특히, texture classes (예를 들어, tile이나 wood)는 loss의 선택에 덜 민감하지만, cable과 transistor를 포함하는 object classes는 $\mathcal{L}_{SSL}$을 사용하는 것이 큰 도움이 되었음을 나타냅니다.

 

 

이러한 관측의 이유를 알아보기 위해, 저자들은 $\mathcal{L}_{SVDD}$, $\mathcal{L}_{SVDD'}$, $\mathcal{L}_{SVDD'} + \mathcal{L}_{SSL}$을 이용해 학습된 encoder를 통해 얻은 object class의 feature에 대해서 t-SNE visualization을 수행하였습니다. (Fig. 10)

 

 

학습이 $\mathcal{L}_{SVDD}$ (Fig. 10(a))나 $\mathcal{L}_{SVDD'}$ (Fig. 10(b))를 이용해서 이루어졌을 때, feature는 uni-modal cluster를 형성하는 것을 확인할 수 있습니다.

 

대조적으로, $\mathcal{L}_{SSL}$은 color나 size와 같은 semantics를 기반으로  Fig. 10(c)에서 보이는 것과 같이 multi-modal feature cluster를 만들어내는 것을 확인할 수 있습니다.

 

이러한 feature의 multi-modal property는 특히 patch 간에 높은 클래스 내 variation을 가지는 object classes에 도움이 됩니다.

 

다른 semantics를 가진 patch들의 feature는 분리되며, 이러한 feature를 사용하는 anomaly inspection는 더욱 정교하고 정확해지게 됩니다.

 

 

Hierarchical encoding

 

 

Section 3.3에서 hierarchical encoder의 사용을 제안하였는데요.

 

Fig. 12는 multiple encoder로부터 나온 multi-scale result를 aggregate 하는 것이 inspection performance를 향상시킨다는 것을 보여줍니다.

 

추가적으로, non-hierarchical encoder를 사용한 ablation study는 hierarchical structure 자체가 성능을 향상시킴을 보여줍니다.

 

 

Hyperparameters

 

 

Eq. 6에서 볼 수 있듯이, hyperparameter $\lambda$는 $\mathcal{L}_{SVDD'}$와 $\mathcal{L}_{SSL}$ 사이의 균형을 맞추는 역할을 하게 됩니다.

 

큰 $\lambda$는 feature의 gathering을 강조하고, 작은 $\lambda$는 informativeness를 촉진시키게 됩니다.

 

놀랍게도, 가장 유리한 $\lambda$는 class에 따라 다릅니다.

 

object class에서의 anomaly는 작은 $\lambda$ 하에서도 잘 탐지되지만, texture class의 경우는 큰 $\lambda$ 하에서 잘 탐지되게 됩니다.

 

Fig. 14는 이러한 차이의 예시를 보여줍니다. 즉, cable class (object)는 $\lambda$가 감소함에 따라 detection performance가 향상되지만, 반면에 wood class (texture)는 반대의 경향을 보여주고 있습니다.

 

 

 

5. Conclusion

 

 

본 논문에서, 저자들은 image anomaly detection과 segmentation을 위한 방법론인 Patch SVDD를 제안하고 있습니다.

 

Deep SVDD와는 다르게, 이미지를 patch level에서 점검하며 defect의 위치를 확인할 수 있습니다.

 

게다가, 추가적인 self-supervised learning은 detection performance를 향상해줍니다.

 

그 결과로, 제안된 방법론은 MVTec AD dataset에서 SOTA performance를 달성하였습니다.

 

 

 

 

여기까지 Patch SVDD 논문 리뷰를 마무리하려고 합니다.

 

논문 내용 중 일부는 제가 생략한 부분도 있어, 관심이 있으시다면 본 논문을 참고하시면 되겠습니다.

 

단순히 이미지를 통째로 feature space에 mapping 하는 것이 아닌, patch 단위로 mapping 하므로 조금 더 localized defect을 잘 찾을 수 있다는 것이 큰 특징이라고 볼 수 있을 것 같습니다.

 

제가 현재 연구하고 있는 부분도 큰 이미지에서 굉장히 작은 크기의 결함 부위를 탐지하는 것을 진행하고 있는데, 이러한 연구에 있어서 굉장히 괜찮은 방법론이라고 생각되어 리뷰를 진행하게 되었습니다.

 

다음 포스팅에서는 해당 논문의 code review를 진행할 예정입니다.

 

이전에는 논문에서 제시하는 모델 하나를 리뷰했었기 때문에 모델 architecture를 위주로 code review를 진행했지만, 해당 논문의 경우 architecture보다는 어떤 방법론으로 anomaly detection을 수행하게 되는지를 봐야 하기 때문에 기존과는 조금 다른 방향으로 code review를 진행하게 될 것 같습니다.

 

그럼 다음 포스팅에서 뵙겠습니다.

 

감사합니다.

 

 

 

 

+ Recent posts