- 이번 포스팅에서는 실시간 객체 탐지 계열 모델중 최고 성능을 유지하고 있는 YOLO 의 최신 모델인 YOLO v12 를 정리하겠습니다.
원래는 YOLO 시리즈를 전부 리뷰하려고 했지만, 이전에 정리한 v4 모델 이후로 후속 모델이 너무 많으므로 그 모든 변경 사항을 전부 정리하는 것은 비효율적이라고 느꼈기에 최신 모델을 정리하려 합니다.
이전까지의 YOLO 시리즈 정리글로 인하여 YOLO 객체 탐지 모델이 무엇인지에 대해 배웠고,
이외의 CNN 기법이라던지 Transformer 에 대해서도 정리하였으므로 크게 어렵지 않게 내용을 이해할 수 있을 것입니다.
- YOLO v12 는 최신 버전 모델답게 v11 대비 정확도 1.2% 가 향상되었으며, 속도 역시 0.1ms 정도 더 빨라졌습니다.
(FlashAttention 을 지원하는 GPU 환경에서...)
V12 모델의 의의로는,
전통적으로 CNN 기반으로만 최적화되던 YOLO 시리즈 모델에 Attention 매커니즘의 장점을 실시간 속도 저하 없이 도입했다는 것입니다.
Attention 의 정확도가 높은 것은 증명된 바이지만, 느리고 무겁다는 단점이 있죠.
YOLO v12 버전에서 어텐션을 사용하면서도 속도까지 향상시킬 수 있던 이유가 무엇인지에 대해 이번 정리글로 알아본다면,
앞으로 비전 관련 경량 Attention 을 어떤식으로 응용해야 할지에 대한 힌트를 얻을 수 있을 것입니다.
- 논문 : YOLOv12: Attention-Centric Real-Time Object Detectors 2025
보시다시피 올해 발표된 최신 모델입니다.
(YOLO v12 설명)
- 먼저, 모델 전체 구성은 아래와 같습니다.
[Input Image: 3 x H x W]
│
▼
[Stem Conv (3×3, stride=2)]
│ → H/2 × W/2
▼
[Conv (3×3, stride=2)]
│ → H/4 × W/4
▼
[R-ELAN Block (Stage 1)]
│ → 채널 증가, 해상도 유지
▼
[Conv (3×3, stride=2)]
│ → H/8 × W/8
▼
[R-ELAN + A² (Stage 2)]
▼
[Conv (3×3, stride=2)]
│ → H/16 × W/16
▼
[R-ELAN + A² (Stage 3)]
▼
[Conv (3×3, stride=2)]
│ → H/32 × W/32
▼
[R-ELAN + A² (Stage 4)]
▼
[SPPF (Spatial Pyramid Pooling Fast)]
▼
[Neck (PAN, BiFPN, etc.)]
▼
[YOLO Head (Detect)]
위에서 백본은 Stem 에서부터 R-ELAN 과 다운 샘플링이 반복되는 구조입니다.
그리고 R-ELAN 백본에서 나온 최하위 특징맵에 Neck 에 속하는 SPPF 를 적용하고,
이후 하위 Neck -> Head 로 전달되며 실행됩니다.
- R-ELAN(Residual Efficient Layer Aggregation Networks)
위에서 보이듯, R-ELAN 은 ELAN 을 개선한 백본 블록입니다.
대표적인 개선사항으로는, Residual 을 적용하여 특징 추출의 효율을 높이고 학습 안정성을 높였다는 것이 있습니다.
ELAN 는 위 구조도에서 보이듯, 여러 레이어의 특징들을 모아서 concat 하는 것이 핵심인 것인데, 이에 Residual 로 안정성을 높였다고 생각하면 되며,
A2 라는 Area Attention 도 백본 안에 존재합니다.
속도가 중요한 백본 모델에 위와 같이 어텐션이 총 6개나 들어가는 것은 굉장히 특이하네요.
- Area Attention
Area Attention 은 전체 영역에 행하는 무거운 Self-Attention 연산을 효율화하는 경량화 Attention 기법입니다.
위 figure2 는 A2 모듈을 시각적으로 나타내는 그림입니다.
Criss cross 방식은 수직 및 수평 방향으로 attention 을 수행하는 방식으로, 행과 열을 모두 고려하므로 넓은 수용 영역을 가지지만 계산량이 큽니다.
window attention 은 Swin Transformer 과 같이 고정된 윈도우 크기 내에서만 어텐션을 수행하여 계산이 적고 빠르지만 문맥 정보가 제한되는 단점이 있으며,
Axial Attention 은 가로방향, 세로방향으로 순차적으로 수행되는 방식으로, 수직, 수평 방향으로 전체 feature map 을 커버하여 계산 효율은 좋지만 연산 순서와 병렬화 문제가 존재합니다.
A2 는 위와 같은 어텐션의 단점을 해소하였으며,
그림에서 보기에는 가장 커버 범위가 넓어서 연산량이 많을 것 같지만,
사실 위에서 커버하는 영역의 처리는 픽셀이 아니라 평균값을 사용하는 것입니다.
4개의 수직 혹은 수평 영역으로 나누고, 각 영역의 평균을 Key로 사용하여, Query 와 Key 의 연산량을 대폭 줄였습니다.
이러한 방식으로 연산량을 굉장히 줄이고, 각 영역을 대표하는 값인 평균 값을 Key로 사용하여 옅지만 광범위하고 계산량 대비 높은 표현력을 지니게 되었죠.
class AreaAttention(nn.Module):
def __init__(self, channels: int):
super().__init__()
self.channels = channels
self.qkv = nn.Conv2d(channels, channels * 3, kernel_size=1, bias=False)
self.proj = nn.Conv2d(channels, channels, kernel_size=1, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, C, H, W = x.shape
qkv = self.qkv(x) # (B, 3C, H, W)
q, k, v = qkv.chunk(3, dim=1) # each (B, C, H, W)
q_flat = q.view(B, C, H * W).permute(0, 2, 1)
h_mid = H // 2
w_mid = W // 2
stripes_k = [
k[:, :, :h_mid, :], # top half
k[:, :, h_mid:, :], # bottom half
k[:, :, :, :w_mid], # left half
k[:, :, :, w_mid:] # right half
]
stripes_v = [
v[:, :, :h_mid, :],
v[:, :, h_mid:, :],
v[:, :, :, :w_mid],
v[:, :, :, w_mid:]
]
k_list = [s.view(B, C, -1).mean(dim=2) for s in stripes_k]
v_list = [s.view(B, C, -1).mean(dim=2) for s in stripes_v]
k_stack = torch.stack(k_list, dim=1)
v_stack = torch.stack(v_list, dim=1)
attn = torch.softmax(
q_flat @ k_stack.transpose(-1, -2) / math.sqrt(C),
dim=-1
)
out_flat = attn @ v_stack
out = out_flat.permute(0, 2, 1).view(B, C, H, W)
return self.proj(out)
위와 같이 어텐션을 적용할 수 있습니다.
k 와 v 를 평균내서 q 와 어텐션 계산을 하죠.
여기서 특이한 점은, YOLO v12 의 A2 에서는 위치 인코딩이 없다는 것인데, stripe 영역 분할 자체가 상하 좌우의 위치 정보를 반영한다고 보기 때문이며, K, V 는 평균 값이기 때문에 공간 정보를 넣어줄 필요가 없는 것이죠.
또한 논문에서는 Flash Attention 을 사용했다고 하는데, 이는 해당 라이브러리를 사용하면 됩니다.
해당 라이브러리를 지원하는 CUDA 계열 GPU 하드웨어가 준비된 상태에서요.
- R-ELAN 구현 코드
import torch
import torch.nn as nn
import math
class ConvBNAct(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size=1, stride=1, padding=0, activation=True):
super().__init__()
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, bias=False)
self.bn = nn.BatchNorm2d(out_channels)
self.act = nn.SiLU() if activation else nn.Identity()
def forward(self, x):
return self.act(self.bn(self.conv(x)))
class ResidualConv(nn.Module):
def __init__(self, channels):
super().__init__()
self.conv1 = ConvBNAct(channels, channels, kernel_size=3, padding=1)
self.conv2 = ConvBNAct(channels, channels, kernel_size=3, padding=1, activation=False)
self.act = nn.SiLU()
def forward(self, x):
out = self.conv1(x)
out = self.conv2(out)
return self.act(out + x)
class AreaAttention(nn.Module):
def __init__(self, channels: int):
super().__init__()
self.channels = channels
self.qkv = nn.Conv2d(channels, channels * 3, kernel_size=1, bias=False)
self.proj = nn.Conv2d(channels, channels, kernel_size=1, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, C, H, W = x.shape
qkv = self.qkv(x) # (B, 3C, H, W)
q, k, v = qkv.chunk(3, dim=1) # each (B, C, H, W)
q_flat = q.view(B, C, H * W).permute(0, 2, 1)
h_mid = H // 2
w_mid = W // 2
stripes_k = [
k[:, :, :h_mid, :], # top half
k[:, :, h_mid:, :], # bottom half
k[:, :, :, :w_mid], # left half
k[:, :, :, w_mid:] # right half
]
stripes_v = [
v[:, :, :h_mid, :],
v[:, :, h_mid:, :],
v[:, :, :, :w_mid],
v[:, :, :, w_mid:]
]
k_list = [s.view(B, C, -1).mean(dim=2) for s in stripes_k]
v_list = [s.view(B, C, -1).mean(dim=2) for s in stripes_v]
k_stack = torch.stack(k_list, dim=1)
v_stack = torch.stack(v_list, dim=1)
attn = torch.softmax(
q_flat @ k_stack.transpose(-1, -2) / math.sqrt(C),
dim=-1
)
out_flat = attn @ v_stack
out = out_flat.permute(0, 2, 1).view(B, C, H, W)
return self.proj(out)
class R_ELANBlock(nn.Module):
def __init__(self, in_channels, out_channels, expansion=0.5, scale=0.01):
super().__init__()
hidden = int(out_channels * expansion)
self.scale = scale
self.shortcut = ConvBNAct(in_channels, out_channels, kernel_size=1, activation=False)
self.proj = ConvBNAct(in_channels, hidden, kernel_size=1)
self.b1 = ResidualConv(hidden)
self.b2 = nn.Sequential(ResidualConv(hidden), ResidualConv(hidden))
self.b3 = nn.Sequential(ResidualConv(hidden), ResidualConv(hidden), ResidualConv(hidden))
self.b4 = nn.Sequential(ResidualConv(hidden), ResidualConv(hidden), ResidualConv(hidden), ResidualConv(hidden))
self.merge12 = ConvBNAct(hidden * 2, hidden, kernel_size=1)
self.merge123 = ConvBNAct(hidden * 2, hidden, kernel_size=1)
self.concat_conv = ConvBNAct(hidden * 4, out_channels, kernel_size=1)
self.attn12_1 = AreaAttention(hidden)
self.attn12_2 = AreaAttention(hidden)
self.attn123_1 = AreaAttention(hidden)
self.attn123_2 = AreaAttention(hidden)
self.attn_out_1 = AreaAttention(out_channels)
self.attn_out_2 = AreaAttention(out_channels)
def forward(self, x):
orig = x
x = self.proj(x)
b1 = self.b1(x)
b2 = self.b2(x)
m12 = self.merge12(torch.cat([b1, b2], dim=1))
m12 = m12 + self.attn12_1(m12) + self.attn12_2(m12)
b3 = self.b3(m12)
m123 = self.merge123(torch.cat([m12, b3], dim=1))
m123 = m123 + self.attn123_1(m123) + self.attn123_2(m123)
b4 = self.b4(m123)
concat = torch.cat([b1, b2, b3, b4], dim=1)
out = self.concat_conv(concat)
out = out + self.attn_out_1(out) + self.attn_out_2(out)
return out + self.shortcut(orig) * self.scale
class R_ELAN(nn.Module):
def __init__(self, in_channels, out_channels, expansion=0.5, scale=0.01):
super().__init__()
self.block = R_ELANBlock(in_channels, out_channels, expansion, scale)
self.act = nn.SiLU()
def forward(self, x):
return self.act(self.block(x))
R-ELAN 전체 코드는 위와 같습니다.
- SPPF(Spatial Pyramid Pooling Fast)
YOLO v12 모델 백본의 마지막 블록을 설명하겠습니다.
SPPF 는 YOLO v5 에서 처음 등장한 구조로, SPP(Spatial Pyramid Pooling) 구조를 간소화하여 연산 속도와 효율을 높인 모듈입니다.
SPPF 의 입력은 최종 특징맵 (B, C, H, W) 입니다.
그리고 출력도 (B, C, H, W) 입니다.
Input → 1×1 Conv → [X, MP(X), MP(MP(X)), MP(MP(MP(X)))] → Concat → 1×1 Conv → Output
구조는 위와 같습니다.
1x1 conv 로 채널 수를 줄여 연산량을 절약하고,
이에 5x5 max pool 을 3번 직렬로 수행하며 중간 결과들을 저장합니다.
그러면 원본인 X 와 Max Pool 이 적용된 결과가 3개 준비되어 총 4개의 결과를 concat 하여 4배의 채널을 가진 특징맵에 1x1 conv 로 다시 처음의 채널 수로 압축하는 프로세스입니다.
class SPPF(nn.Module):
def __init__(self, in_channels, out_channels, pool_kernel=5):
super().__init__()
hidden = in_channels // 2
self.conv1 = ConvBNAct(in_channels, hidden, kernel_size=1)
self.pool = nn.MaxPool2d(kernel_size=pool_kernel, stride=1, padding=pool_kernel // 2)
self.conv2 = ConvBNAct(hidden * 4, out_channels, kernel_size=1)
def forward(self, x):
x = self.conv1(x)
y1 = self.pool(x)
y2 = self.pool(y1)
y3 = self.pool(y2)
return self.conv2(torch.cat([x, y1, y2, y3], dim=1))
- Neck 모델
SPPF 에서 반환된 특징맵을 다음으로 넘기기 전에,
다양한 스케일 통합, 상/하위 특징간 상호작용으로 인한 특징 강화 등의 역할을 수행하는 다음 Neck 모델에 대해 알아보겠습니다.
YOLO v12 의 Neck 은 일반적으로 PAN(Path Aggregation Network) 이나 BiFPN(Bidirectional Feature Pyramid Network) 중 하나를 선택하여 사용합니다. (논문 및 공식 구현은 BiFPN 사용)
그외에는 실험적으로 RepNeck 이나 BiFusion 역시 도입되기도 하였는데,
1. PAN: 연산량·파라미터가 가장 적어 실시간 속도에 유리하지만, BiFPN 대비 정확도(mAP)가 소폭 낮습니다.
2. BiFPN: 양방향 피라미드 구조로 PAN보다 복잡하고 느리지만, 다중 스케일 정보 융합 성능이 좋아 정확도가 더 높습니다.
위와 같습니다.
목적에 맞게 선택해 사용하면 됩니다.
- PAN(Path Aggregation Network)
Bottom-up, Top-down 피처 경로를 사용하는 피처 피라미드 구조로, 다단계 피처맵을 결합하는 역할을 합니다.
1. 백본에서 추출된 하위 3단계 레이어의 특징맵을 가져옵니다.
P3 : 80x80 256C
P4 : 40x40 512C
P5 : 20x20 1024C
해상도가 낮아질수록 큰 객체 + 고수준 정보가 들어있고,
커질수록 작은 객체, 저수준 정보가 들어있습니다.
2. P5 에서부터 업샘플링을 진행합니다.
40x40 으로 업샘플링 후 concat 한 후, 512C 로 다시 1x1 conv 를 하여 P4` 를 생성합니다.
P4` 를 업샘플링하여 P3 크기인 80x80 으로 만든 후, 다시 concat 후 1x1 conv 로 256C 로 만들어 P3` 로 만듭니다.
3. 이번에는 P3` 부터 아래 방향으로,
P3` 다운 샘플링 및 concat -> 1x1 conv 로 P4`` 생성
P4`` 다운 샘플링 및 concat -> 1x1 conv 로 P5`` 생성
4. 위와 같이 한번 정보가 섞인 최종 출력 피처맵인 P3`, P4``, P5`` 를 Head 로 전달합니다.
- BiFPN(Bidirectional Feature Pyramid Network)
PAN 보다 복잡하고 무겁지만 정확도가 높아지는 방법입니다.
프로세스는 PAN 과 유사한데, top-down 과 bottom-up 을 한번이 아니라 여러번 반복 하며, feature fusion 시에는 concat 대신 weighted sum 기반의 병합을 합니다.
피쳐맵 단위로 가중치를 부여하는 것으로,
# P4_td, P4: shape = (B, C, H, W)
# learnable weights
w1 = nn.Parameter(torch.ones(1), requires_grad=True)
w2 = nn.Parameter(torch.ones(1), requires_grad=True)
# Fusion
weight_sum = w1 + w2 + 1e-4
F_out = (w1 * P4_td + w2 * P4) / weight_sum
위와 같은 수식으로 피쳐맵별 가중치를 부여합니다.(위는 P4 에 가중치 적용)
- YOLO v12 한계
YOLO v12 는 Attention 을 통한 Detection 모델에 중점이 맞춰져 있기 때문에 Segmentation 및 추가 태스크 확장은 연구가 더 필요하다고 합니다.
또한 어텐션 속도 향상을 위해 도입한 FlashAttention 기술은 지원하는 GPU 가 한정적(CUDA 계열 최신 GPU)이라고 합니다.
즉, FlashAttention 를 사용한 논문 성능이 v11 보다 근소하게 향상되었으므로, 이를 사용하지 못하는 환경에서는 v11 보다 성능이 떨어질 수도 있습니다...;;
(결론)
- 여러모로 실험적인 YOLO 모델인 것으로 보여졌습니다.
앞으로 어떻게 발전할지에 대해서는 차기 모델이 될 v13 이나 v14 를 보면 알 수 있을 것 같은데,
개인적으로는 무거운 attention 을 도입하기 위해 특화된 GPU 를 사용하면서도 그렇게까지 극적인 성능 향상을 보이지 못한 v12 보다는, 더 안정적인 버전을 사용하는 것이 낫다고 생각되었습니다.
이상입니다.