

원본 이미지 (MVTec AD) → 특징 맵(Feature Map) → 가우시안 노이즈 → 노이즈가 더해진 특징 맵의 과정을 시각적으로 확인할 수 있도록 코드를 작성해보자.
참고 사항: 특징 맵(Feature Map)은 채널(Channel)이 매우 많기(예: 2048개) 때문에 화면에 한꺼번에 보여줄 수 없다. 따라서 시각화 시에는 첫 번째 채널(Channel 0)의 2차원 평면을 추출하여 데이터가 어떻게 변하는지 직관적으로 보여주도록 구현했다.
import torch
import torch.nn as nn
import torchvision.models as models
import torchvision.transforms as transforms
from PIL import Image
import numpy as np
import os
import glob
import random
import matplotlib.pyplot as plt
# --- [SimpleNet 핵심 모듈 구현] ---
class SimpleNetComponent(nn.Module):
def __init__(self, feature_dim):
super(SimpleNetComponent, self).__init__()
# 1. Feature Extractor: 사전 학습된 ResNet50 사용
resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
self.feature_extractor = nn.Sequential(*(list(resnet.children())[:-2]))
# 2. Feature Adapter: 논문의 설계를 따른 단일 FC 레이어 (bias 없음)
self.feature_adapter = nn.Linear(feature_dim, feature_dim, bias=False)
def forward(self, x):
with torch.no_grad():
features = self.feature_extractor(x)
# [B, C, H, W] -> [B, H, W, C]로 변환하여 Linear 적용 후 다시 복원
features_permuted = features.permute(0, 2, 3, 1)
adapted_features = self.feature_adapter(features_permuted)
adapted_features = adapted_features.permute(0, 3, 1, 2)
return adapted_features
# --- [시각화 및 실행 로직] ---
def find_random_test_image(base_dir):
"""mvtec_ad 폴더 내에서 무작위로 테스트용 이미지(.png)를 1장 선택합니다."""
search_pattern = os.path.join(base_dir, "*", "test", "**", "*.png")
image_files = glob.glob(search_pattern, recursive=True)
return random.choice(image_files) if image_files else None
def visualize_transformation(image_path, model_components, sigma=0.015):
# 1. 이미지 전처리
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
img = Image.open(image_path).convert('RGB')
img_tensor = transform(img).unsqueeze(0)
# 2. 특징 추출 및 어댑터 적용 (Feature Map 생성)
adapted_features = model_components(img_tensor)
# 3. 가우시안 노이즈 생성 및 주입 (Feature Space에서)
noise = torch.randn_like(adapted_features) * sigma
noisy_features = adapted_features + noise
# 4. 시각화를 위한 데이터 추출 (첫 번째 채널만 사용)
# 채널 차원(Dim 1)에서 0번 인덱스만 가져옴
original_img_np = np.array(img.resize((256, 256))) # 시각화용 크기 조정
feature_map_np = adapted_features[0, 0].cpu().detach().numpy() # [H, W]
noise_map_np = noise[0, 0].cpu().detach().numpy() # [H, W]
noisy_feature_map_np = noisy_features[0, 0].cpu().detach().numpy() # [H, W]
# 5. 시각화 (4개의 패널)
fig, axes = plt.subplots(1, 4, figsize=(22, 5))
# (A) 원본 이미지
axes[0].imshow(original_img_np)
axes[0].set_title("1. Original Image", fontsize=14)
axes[0].axis('off')
# (B) Feature Map (첫 번째 채널)
im1 = axes[1].imshow(feature_map_np, cmap='viridis')
axes[1].set_title("2. Feature Map (Ch 0)", fontsize=14)
axes[1].axis('off')
fig.colorbar(im1, ax=axes[1], fraction=0.046, pad=0.4)
# (C) Gaussian Noise (첫 번째 채널의 절대값)
im2 = axes[2].imshow(np.abs(noise_map_np), cmap='magma')
axes[2].set_title(f"3. Gaussian Noise\n($\sigma={sigma}$)", fontsize=14)
axes[2].axis('off')
fig.colorbar(im2, ax=axes[2], fraction=0.046, pad=0.4)
# (D) Noisy Feature Map (첫 번째 채널)
im3 = axes[3].imshow(noisy_feature_map_np, cmap='viridis')
axes[3].set_title("4. Noisy Feature Map\n(Original + Noise)", fontsize=14)
axes[3].axis('off')
fig.colorbar(im3, ax=axes[3], fraction=0.046, pad=0.4)
plt.tight_layout()
plt.show()
if __name__ == "__main__":
DATASET_ROOT = "mvtec_ad"
if not os.path.exists(DATASET_ROOT):
print(f"❌ 오류: '{DATASET_ROOT}' 폴더를 찾을 수 없습니다.")
else:
test_image = find_random_test_image(DATASET_ROOT)
if test_image is None:
print("❌ 오류: 이미지를 찾을 수 없습니다.")
else:
print(f"📸 선택된 이미지: {test_image}")
# 모델 및 생성기 초기화
feature_dim = 2048
model = SimpleNetComponent(feature_dim)
# 시각화를 위해 노이즈 강도를 조금 높게 설정할 수 있습니다 (예: 0.1)
visualize_transformation(test_image, model, sigma=0.1)

sigma 값 변화에 따른 가우시안 노이즈 맵의 변화를 시각적으로 확인해본다.
sigma 값을 0, 0.05, 0.1, 0.15, 0.2로 변환하면서 가우시안 노이즈 맵의 변화를 시각화하면 다음과 같다. 노이즈 맵의 크기는 28x28로 한다.
σ=0: 모든 값이 0이므로 검게(또는 가장 어둡게) 표시된다.
σ 증가 (0.05 → 0.2): 색상이 점점 더 밝아지거나 강렬해지는 것을 볼 수 있다. 이는 특징 공간에서 정상 분포로부터 이상치가 벗어나는 범위(Euclidean distance)가 넓어짐을 의미한다.
saved: gaussian_noise_sigma_comparison.png
sigma | actual std | max |value|
0.00 | 0.00000 | 0.00000
0.05 | 0.05025 | 0.19497
0.10 | 0.09664 | 0.31973
0.15 | 0.15466 | 0.46718
0.20 | 0.20010 | 0.60229
σ = 0: 노이즈가 전혀 없어 완전히 균일한(회색) 맵 — 이는 논문 Eq.(6)에서 q- = q + 0 = q, 즉 이상 특징이 정상 특징과 완전히 동일해지는 경우입니다. 판별기가 정상/이상을 구분할 근거가 사라집니다.
σ가 커질수록: 픽셀 값의 분산이 커지면서(빨강/파랑 대비가 뚜렷해짐) 노이즈 맵이 시각적으로 더 "거칠어(noisy)"집니다. 실제 측정된 표준편차도 설정한 σ와 거의 정확히 일치합니다.
논문 기본값인 σ=0.015는 이 그래프의 σ=0.05보다도 훨씬 작은 값으로, "정상 분포 경계 바로 바깥의 아주 미세한 교란"만 주는 수준임을 시각적으로도 짐작하실 수 있습니다.
간단한 통계 개념이라 코드 없이 설명드리겠습니다.
actual std (실제 표준편차)noise.std()로 계산한 값으로, 28×28 = 784개의 노이즈 픽셀 값들이 실제로 얼마나 퍼져있는지를 측정한 것입니다.actual std는 np.random.normal(scale=σ)로 실제 뽑힌 784개 샘플들의 표본 표준편차(sample standard deviation)입니다.actual std는 σ에 정확히 수렴하지만, 784개라는 유한한 샘플만 뽑았기 때문에 약간의 오차가 존재합니다.actual std는 0.0966로 나온 것처럼, 완벽히 0.1이 아니라 그 근처 값이 나옵니다.np.zeros), actual std도 정확히 0입니다.max |value| (최대 절대값)np.abs(noise).max()로 계산한 값으로, 784개 노이즈 픽셀 중 절댓값이 가장 큰 값입니다. 즉 노이즈가 가장 세게 튄 지점의 크기입니다.
가우시안 분포의 특성상 대부분의 값은 평균(0) 근처에 몰려있지만, 가끔 평균에서 멀리 벗어난 값(꼬리, tail)이 나오기도 합니다. max |value|는 이 "가장 극단적으로 튄 값"을 보여줍니다.
경험적으로, 정규분포에서 784개 샘플을 뽑으면 최댓값은 보통 σ의 3~3.5배 정도 나오는 경우가 많습니다(3-sigma rule과 관련). 실제로 표를 보면:
이는 우연이 아니라, 표본 개수(784개)가 고정되어 있을 때 정규분포에서 기대되는 최댓값의 통계적 성질(극값 이론) 때문입니다.
요약하면: actual std는 "노이즈가 평균적으로 얼마나 퍼져있는가"를, max |value|는 "가장 심하게 튄 값이 얼마나 큰가"를 보여주는 지표입니다. 두 값 모두 σ가 커질수록 함께 커지지만, max |value|가 actual std보다 훨씬 빠르게 커 보이는 이유는 그것이 평균이 아니라 "극단값" 하나만 보여주기 때문입니다.
"""
sigma(가우시안 노이즈 표준편차) 값에 따른 노이즈 맵 변화 시각화
SimpleNet 논문 Eq.(6): q- = q + eps, eps ~ N(0, sigma^2)
여기서는 실제 특징맵(q) 없이, eps(노이즈) 자체만 28x28 크기로 생성해
sigma에 따라 노이즈의 '퍼짐 정도'가 어떻게 달라지는지 시각적으로 비교합니다.
"""
import numpy as np
import matplotlib.pyplot as plt
MAP_SIZE = 28 # 논문 기본 설정과 동일한 공간 해상도 (H0=W0=28)
SIGMAS = [0.0, 0.05, 0.1, 0.15, 0.2] # 비교할 sigma 값들
SEED = 0
rng = np.random.default_rng(SEED)
# 모든 sigma에서 동일한 색상 스케일을 쓰기 위해, 가장 큰 sigma의 노이즈 범위를 기준으로 vmin/vmax 설정
noise_maps = []
for sigma in SIGMAS:
noise = rng.normal(loc=0.0, scale=sigma, size=(MAP_SIZE, MAP_SIZE)) if sigma > 0 \
else np.zeros((MAP_SIZE, MAP_SIZE))
noise_maps.append(noise)
vmax = max(np.abs(m).max() for m in noise_maps if np.abs(m).max() > 0)
vmin = -vmax
fig, axes = plt.subplots(1, len(SIGMAS), figsize=(4 * len(SIGMAS), 4.5))
for ax, sigma, noise in zip(axes, SIGMAS, noise_maps):
im = ax.imshow(noise, cmap="coolwarm", vmin=vmin, vmax=vmax)
ax.set_title(f"σ = {sigma}\nstd(actual) = {noise.std():.4f}", fontsize=12)
ax.set_xticks([])
ax.set_yticks([])
fig.suptitle(f"Gaussian Noise Map ({MAP_SIZE}x{MAP_SIZE}) by σ — ε ~ N(0, σ²)",
fontsize=14, y=1.03)
# 공통 컬러바
cbar = fig.colorbar(im, ax=axes, fraction=0.02, pad=0.02)
cbar.set_label("noise value", fontsize=11)
fig.savefig("gaussian_noise_sigma_comparison.png", dpi=150, bbox_inches="tight")
print("saved: gaussian_noise_sigma_comparison.png")
# 참고용: sigma별 실제 표준편차/최대절대값 출력
print("\nsigma | actual std | max |value|")
for sigma, noise in zip(SIGMAS, noise_maps):
print(f"{sigma:5.2f} | {noise.std():.5f} | {np.abs(noise).max():.5f}")