저번에 MediaPipe Hands 논문을 리뷰하면서 손바닥을 탐지하는 모델과 랜드마크를 탐지하는 모델로 나누어 two-stage로 진행한다는 것을 확인했었죠. 이번 글에서는 두 모델 중 손바닥 탐지 모델을 분석해 보도록 하겠습니다. GitHub 페이지에 코드가 있기 때문에 확인해 보시는 것도 좋을 것 같습니다.
ResModule
GitHub 페이지에서 blazepalm.py 파일을 확인해 보면, ResModule, ResBlock, PalmDetector로 구성되어 있는 것을 확인할 수 있습니다. 먼저 ResModule은 ResNet 모델과 유사한 잔차 연결(residual connection)을 구현하는 기본 모듈로, 2개의 convolution layer로 구성되어 있습니다.
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
class ResModule(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super(ResModule, self).__init__()
self.stride = stride
self.channel_pad = out_channels - in_channels
kernel_size = 3 # kernel size is always 3
# pooling, padding 설정
if stride == 2:
self.max_pool = nn.MaxPool2d(kernel_size=stride, stride=stride)
padding = 0
else:
padding = (kernel_size - 1) // 2
self.convs = nn.Sequential(
nn.Conv2d(in_channels=in_channels, out_channels=in_channels,
kernel_size=kernel_size, stride=stride, padding=padding,
groups=in_channels, bias=True),
nn.Conv2d(in_channels=in_channels, out_channels=out_channels,
kernel_size=1, stride=1, padding=0, bias=True),
)
self.act = nn.ReLU(inplace=True)
def forward(self, x):
if self.stride == 2:
h = F.pad(x, (0, 2, 0, 2), "constant", 0)
x = self.max_pool(x)
else:
h = x
if self.channel_pad > 0:
x = F.pad(x, (0, 0, 0, 0, 0, self.channel_pad), "constant", 0)
return self.act(self.convs(h) + x)
Convolution layer를 통과한 후, ReLU 활성화 함수를 사용해 비선형성을 부여하고 stride의 크기만큼 입력의 공간적 크기를 줄이는 형태입니다. 모델의 구조는 아래 그림과 같습니다.
ResBlock
다음은 ResBlock입니다. 여러 ResModule을 스택 형태로 쌓은 블록 클래스로, 복잡한 특징을 학습하기 위해 사용되며, 7개의 ResModule이 순차적으로 적용됩니다.
class ResBlock(nn.Module):
def __init__(self, in_channels):
super(ResBlock, self).__init__()
# ResModule 7개를 순차적으로 적용
layers = [ResModule(in_channels, in_channels) for _ in range(7)]
self.f = nn.Sequential(*layers)
def forward(self, x):
return self.f(x)
PalmDetector
지금까지 ResModule, ResBlock을 분석한 이유는 바로 PalmDetector을 이해하기 위해서입니다. MediaPipe Hands에서 사용하는 손바닥 탐지기 클래스로, backbone 네트워크, 업샘플링 네트워크, 출력 레이어로 구성되어 있습니다.
def __init__(self):
super(PalmDetector, self).__init__()
self.backbone1 = nn.Sequential(
nn.ConstantPad2d((0, 1, 0, 1), value=0.0),
nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, stride=2, padding=0, bias=True),
nn.ReLU(inplace=True),
ResBlock(32),
ResModule(32, 64, stride=2),
ResBlock(64),
ResModule(64, 128, stride=2),
ResBlock(128)
)
self.backbone2 = nn.Sequential(
ResModule(128, 256, stride=2),
ResBlock(256)
)
self.backbone3 = nn.Sequential(
ResModule(256, 256, stride=2),
ResBlock(256)
)
self.upscale8to16 = nn.Sequential(
nn.ConvTranspose2d(in_channels=256, out_channels=256, kernel_size=2, stride=2, padding=0, bias=True),
nn.ReLU(inplace=True)
)
self.scaled16add = ResModule(256, 256)
self.upscale16to32 = nn.Sequential(
nn.ConvTranspose2d(in_channels=256, out_channels=128, kernel_size=2, stride=2, padding=0, bias=True),
nn.ReLU(inplace=True),
)
self.scaled32add = ResModule(128, 128)
self.class_32 = nn.Conv2d(in_channels=128, out_channels=2, kernel_size=1, stride=1, padding=0, bias=True)
self.class_16 = nn.Conv2d(in_channels=256, out_channels=2, kernel_size=1, stride=1, padding=0, bias=True)
self.class_8 = nn.Conv2d(in_channels=256, out_channels=6, kernel_size=1, stride=1, padding=0, bias=True)
self.reg_32 = nn.Conv2d(in_channels=128, out_channels=36, kernel_size=1, stride=1, padding=0, bias=True)
self.reg_16 = nn.Conv2d(in_channels=256, out_channels=36, kernel_size=1, stride=1, padding=0, bias=True)
self.reg_8 = nn.Conv2d(in_channels=256, out_channels=108, kernel_size=1, stride=1, padding=0, bias=True)
Backbone 네트워크는 3개의 블록(backbone1, backbone2, backbone3)로 구성되어 있습니다.
backbone1
self.backbone1 = nn.Sequential(
nn.ConstantPad2d((0, 1, 0, 1), value=0.0),
nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, stride=2, padding=0, bias=True),
nn.ReLU(inplace=True),
ResBlock(32),
ResModule(32, 64, stride=2),
ResBlock(64),
ResModule(64, 128, stride=2),
ResBlock(128)
)
backbone1은 1개의 convolution layer, 3개의 ResBlock, 2개의 ResModule로 이루어져 있습니다. 입력 이미지(256x256)가 주어지면 이미지의 오른쪽과 아래쪽에 zero-padding을 추가하고 3x3x3 필터를 적용해 32x128x128 feature map을 생성합니다. 이후 ResBlock, ResModule을 통과하면서 채널 수는 증가하고, 공간 크기는 줄어드는 방식으로 작동하며 최종적으로 128x32x32 출력이 생성됩니다.
글만으로는 이해하기 어려우니 아래 그림을 참고하면 더 쉽게 이해할 수 있을 것 같습니다.
backbone2
self.backbone2 = nn.Sequential(
ResModule(128, 256, stride=2),
ResBlock(256)
)
backbone2는 backbone1에서 출력된 특징을 추가적으로 다운샘플링하며, feature map의 크기를 256으로 증가시킵니다.
backbone3
self.backbone3 = nn.Sequential(
ResModule(256, 256, stride=2),
ResBlock(256)
)
backbone3는 backbone2의 출력을 기반으로 더 높은 수준의 특징을 추출합니다.
upscale*to*, scaled*add
self.upscale8to16 = nn.Sequential(
nn.ConvTranspose2d(in_channels=256, out_channels=256, kernel_size=2, stride=2, padding=0, bias=True),
nn.ReLU(inplace=True)
)
self.scaled16add = ResModule(256, 256)
self.upscale16to32 = nn.Sequential(
nn.ConvTranspose2d(in_channels=256, out_channels=128, kernel_size=2, stride=2, padding=0, bias=True),
nn.ReLU(inplace=True),
)
self.scaled32add = ResModule(128, 128)
upscale8to16은 deconvolution을 통해 입력값의 크기를 2배(8x8 → 16x16)로 증가시키고, scaled16add를 통해 업샘플링된 데이터를 추가로 처리하여 중요한 정보를 보강합니다. upscale16to32도 동일한 작업(16x16 → 32x32)을 하지만 출력 채널이 256에서 128로 반감된다는 차이점이 있습니다.
upscale8to16/scaled16add, upscale16to32/scaled32add는 채널 수만 다르고 다른 부분은 모두 동일하기 때문에 일부 그림만 첨부하겠습니다.
class_*, reg_*
self.class_32 = nn.Conv2d(in_channels=128, out_channels=2, kernel_size=1, stride=1, padding=0, bias=True)
self.class_16 = nn.Conv2d(in_channels=256, out_channels=2, kernel_size=1, stride=1, padding=0, bias=True)
self.class_8 = nn.Conv2d(in_channels=256, out_channels=6, kernel_size=1, stride=1, padding=0, bias=True)
self.reg_32 = nn.Conv2d(in_channels=128, out_channels=36, kernel_size=1, stride=1, padding=0, bias=True)
self.reg_16 = nn.Conv2d(in_channels=256, out_channels=36, kernel_size=1, stride=1, padding=0, bias=True)
self.reg_8 = nn.Conv2d(in_channels=256, out_channels=108, kernel_size=1, stride=1, padding=0, bias=True)
class_32는 32x32 해상도에서 2개의 클래스 확률을, class_16 은 16x16 해상도에서 2개의 클래스 확률을, class_8은 8x8 해상도에서 6개의 클래스 확률을 출력합니다.
reg_32는 32x32 해상도에서 36개의 회귀 값(바운딩 박스 좌표)을, reg_16은 16x16 해상도에서 36개의 회귀 값을, reg_8은 8x8 해상도에서 108개의 회귀 값을 출력합니다.
구체적인 설명과 구조는 forward 함수를 분석하면서 알아보도록 하겠습니다.
forward 함수
지금까지 constructor에서 모델의 구성 요소를 살펴봤습니다. 실제 모델의 구조는 forward 함수에서 확인할 수 있는데요, 하나씩 보도록 하겠습니다.
def forward(self, x):
b1 = self.backbone1(x) # 32x32
b2 = self.backbone2(b1) # 16x16
b3 = self.backbone3(b2) # 8x8
b2 = self.upscale8to16(b3) + b2 # 16x16
b2 = self.scaled16add(b2) # 16x16
b1 = self.upscale16to32(b2) + b1 # 32x32
b1 = self.scaled32add(b1)
c8 = self.class_8(b3).permute(0, 2, 3, 1).reshape(-1, 384, 1)
c16 = self.class_16(b2).permute(0, 2, 3, 1).reshape(-1, 512, 1)
c32 = self.class_32(b1).permute(0, 2, 3, 1).reshape(-1, 2048, 1)
r8 = self.reg_8(b3).permute(0, 2, 3, 1).reshape(-1, 384, 18)
r16 = self.reg_16(b2).permute(0, 2, 3, 1).reshape(-1, 512, 18)
r32 = self.reg_32(b1).permute(0, 2, 3, 1).reshape(-1, 2048, 18)
c = torch.cat([c32, c16, c8], dim=1)
r = torch.cat([r32, r16, r8], dim=1)
return c, r
첫 세 줄을 먼저 보면, downsampling이 이루어지는 것을 확인할 수 있습니다.
b1 = self.backbone1(x) # 32x32
b2 = self.backbone2(b1) # 16x16
b3 = self.backbone3(b2) # 8x8
이 코드는 3x256x256 입력 이미지를 128x32x32 → 256x16x16 → 256x8x8 순으로 채널 수는 증가시키고, 공간 크기는 감소시키는 작업을 합니다. backbone1, backbone2, backbone3의 구조는 위에서 확인하실 수 있습니다.
b2 = self.upscale8to16(b3) + b2 # 16x16
b2 = self.scaled16add(b2) # 16x16
b1 = self.upscale16to32(b2) + b1 # 32x32
b1 = self.scaled32add(b1)
b2는 8x8 크기인 b3을 16x16으로 업스케일한 결과와 더해져 특성이 보강되고, b1도 같은 방식으로 b2와 더해집니다.
c8 = self.class_8(b3).permute(0, 2, 3, 1).reshape(-1, 384, 1)
c16 = self.class_16(b2).permute(0, 2, 3, 1).reshape(-1, 512, 1)
c32 = self.class_32(b1).permute(0, 2, 3, 1).reshape(-1, 2048, 1)
r8 = self.reg_8(b3).permute(0, 2, 3, 1).reshape(-1, 384, 18)
r16 = self.reg_16(b2).permute(0, 2, 3, 1).reshape(-1, 512, 18)
r32 = self.reg_32(b1).permute(0, 2, 3, 1).reshape(-1, 2048, 18)
c = torch.cat([c32, c16, c8], dim=1)
r = torch.cat([r32, r16, r8], dim=1)
이후 분류 결과를 계산하게 되는데요, c에 해당되는 값은 손의 유무, r에 해당되는 값은 bounding box의 좌표(ymin, ymax, xmin, xmax; 4개), 그리고 일부 랜드마크의 좌표(7*2 = 14개)가 더해진 18개의 출력값이 나오게 됩니다.
이제 모델의 구조를 보면 어떤 방식으로 구성되어 있는지 더 자세하게 이해할 수 있을 것 같습니다 🎄🎅🏻
'AI' 카테고리의 다른 글
[AI] MediaPipe Hands: 랜드마크 탐지 모델 분석 (0) | 2025.01.01 |
---|---|
3D Pose Estimation (0) | 2024.11.21 |
합성곱 신경망 (Convolutional Neural Network, CNN) (0) | 2024.09.01 |
전이 학습 (Transfer Learning) (0) | 2024.08.26 |
Cross Entropy Loss (0) | 2024.08.20 |