키포인트 검출과 자세 추정(Keypoint Detection & Pose Estimation)
자세(pose)는 순서가 있는 키포인트(keypoint)의 집합입니다. 키포인트 검출기(keypoint detector)는 히트맵 회귀기(heatmap regressor)입니다. 나머지는 대부분 장부 정리(bookkeeping)에 가깝습니다.
유형: Build
언어: Python
선수 조건: Phase 4 Lesson 06(Detection), Phase 4 Lesson 07(U-Net)
소요 시간: 약 45분
학습 목표
- 하향식(top-down) 자세 추정과 상향식(bottom-up) 자세 추정을 구분하고 각각 언제 쓰는지 설명합니다.
- K개의 키포인트에 대해 가우시안(Gaussian) 목표를 가진 히트맵(heatmap)을 회귀하고, 추론(inference) 단계에서 키포인트 좌표를 추출합니다.
- 부위 친화도 필드(Part Affinity Fields; PAFs)가 무엇인지 설명하고, 상향식 파이프라인(pipeline)이 키포인트를 인스턴스(instance) 단위로 묶는 방식을 이해합니다.
- MediaPipe Pose 또는 MMPose를 운영 환경의 키포인트 추정에 사용하고 출력 형식을 이해합니다.
문제
키포인트 작업은 여러 이름 뒤에 숨어 있습니다. 사람 자세는 17개의 신체 관절(body joint), 얼굴 랜드마크(landmark)는 68개 또는 478개의 점, 손은 21개의 점을 사용합니다. 동물 자세, 로봇 객체 자세, 의료 해부학 랜드마크도 같은 계열입니다. 모두 하나의 구조를 공유합니다. 객체에서 K개의 이산적인 점을 검출하고 그 (x, y) 좌표를 출력하는 것입니다.
자세 추정은 모션 캡처(motion capture), 피트니스 앱, 스포츠 분석(sports analytics), 제스처 제어(gesture control), 애니메이션, 증강 현실 가상 착용(AR try-on), 로봇 파지(robotic grasping)의 기반입니다. 2D는 운영 품질까지 성숙했습니다. 3D 자세, 즉 단일 카메라에서 월드 좌표계(world coordinate)의 관절 위치(joint position)를 추정하는 문제는 현재 연구의 최전선입니다.
엔지니어링 측면의 핵심 질문은 규모(scale)입니다. 단일 이미지의 단일 인물 자세는 20ms 안에 풀어야 하는 문제입니다. 하지만 군중 속 다인 자세(multi-person pose)를 30fps로 처리하는 일은 다른 아키텍처(architecture)가 필요한 별개의 문제입니다.
개념
하향식(top-down)과 상향식(bottom-up)
flowchart LR
subgraph TD["Top-down pipeline"]
A1["사람 박스 검출"] --> A2["각 박스 크롭"]
A2 --> A3["박스별 키포인트 모델<br/>(HRNet, ViTPose)"]
end
subgraph BU["Bottom-up pipeline"]
B1["이미지 전체 한 번 통과"] --> B2["모든 키포인트 히트맵<br/>+ 연관 필드(association field)"]
B2 --> B3["키포인트를 인스턴스로<br/>묶기(greedy matching)"]
end
style TD fill:#dbeafe,stroke:#2563eb
style BU fill:#fef3c7,stroke:#d97706
- 하향식(Top-down): 먼저 사람을 검출한 뒤, 각 크롭(crop)에 대해 사람별 키포인트 모델을 실행합니다. 정확도가 가장 높지만 사람 수에 선형으로 비례해 비용이 늘어납니다.
- 상향식(Bottom-up): 한 번의 순전파(forward pass)로 모든 키포인트와 연관 필드(association field)를 예측한 뒤 묶습니다. 군중 크기와 무관하게 시간이 거의 일정합니다.
하향식(HRNet, ViTPose)은 정확도 선두이고, 상향식(OpenPose, HigherHRNet)은 붐비는 장면에서 처리량이 강합니다.
히트맵 회귀(Heatmap regression)
(x, y)를 직접 회귀하지 않고, 각 키포인트마다 실제 위치를 중심으로 한 가우시안 덩어리(Gaussian blob)가 있는 H x W 히트맵을 예측합니다.
target[k, y, x] = exp(-((x - cx_k)^2 + (y - cy_k)^2) / (2 sigma^2))
추론(inference) 단계에서는 각 히트맵의 최댓값 위치(argmax)가 예측된 키포인트 위치입니다.
히트맵이 직접 좌표 회귀보다 나은 이유는 신경망의 공간 구조(spatial structure), 즉 합성곱 특징 맵(conv feature map)이 공간적 출력(spatial output)과 자연스럽게 맞기 때문입니다. 가우시안 목표는 정규화(regularisation) 효과도 제공합니다. 작은 위치 오차는 작은 손실을 만들고, 바로 0점 처리가 되지는 않습니다.
부분 픽셀 정밀 위치 추정(Sub-pixel localisation)
최댓값 위치(argmax)는 정수 좌표를 줍니다. 부분 픽셀 정밀도(sub-pixel precision)가 필요하면 최댓값 위치와 주변 이웃에 포물선(parabola)을 맞추거나, 잘 알려진 오프셋(offset) (dx, dy) = 0.25 * (heatmap[y, x+1] - heatmap[y, x-1], ...) 방향을 사용합니다.
부위 친화도 필드(Part Affinity Fields; PAFs)
PAF는 상향식 연관(bottom-up association)을 위한 OpenPose의 핵심 아이디어입니다. 연결된 키포인트 쌍, 예를 들어 왼쪽 어깨와 왼쪽 팔꿈치마다 한쪽에서 다른 쪽을 가리키는 단위 벡터(unit vector)를 담은 2채널 필드를 예측합니다. 어깨 후보와 팔꿈치 후보를 연결할 때는 두 후보를 잇는 선을 따라 PAF를 적분합니다. 가장 큰 적분값을 가진 쌍이 매칭됩니다.
각 연결(limb)에 대해:
PAF channels: 2(unit vector x, y)
Line integral: 표본 지점(sample point)마다 (PAF . line_direction)을 합산
더 높은 적분값(integral) = 더 강한 매칭(match)
깔끔한 방식이며, 사람별 크롭 없이도 임의의 군중 크기로 확장됩니다.
COCO 키포인트(COCO keypoints)
표준 신체 자세 데이터셋(body-pose dataset)입니다. 사람마다 17개의 키포인트를 사용하고, 정확한 키포인트 비율(Percentage of Correct Keypoints; PCK)과 객체 키포인트 유사도(Object Keypoint Similarity; OKS)를 지표로 씁니다. OKS는 키포인트 버전의 IoU이며 COCO mAP@OKS가 보고하는 값입니다.
2D와 3D
- 2D 자세(2D pose): 이미지 좌표입니다. MediaPipe, HRNet, ViTPose로 운영 품질까지 해결되어 있습니다.
- 3D 자세(3D pose): 월드 좌표계(world coordinate) 또는 카메라 좌표계(camera coordinate)입니다. 아직 활발한 연구 주제입니다. 일반적인 접근은 다음과 같습니다.
- 작은 MLP로 2D 예측을 3D로 끌어올립니다(VideoPose3D).
- 이미지에서 직접 3D를 회귀합니다(PyMAF, MHFormer).
- 다시점 설정(multi-view setup; CMU Panoptic)을 정답(ground truth)으로 사용합니다.
만들어 보기
Step 1: 가우시안 히트맵 목표(Gaussian heatmap target)
import numpy as np
import torch
def gaussian_heatmap(size, cx, cy, sigma=2.0):
yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
return np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * sigma ** 2)).astype(np.float32)
hm = gaussian_heatmap(64, 32, 32, sigma=2.0)
print(f"최댓값: {hm.max():.3f}, 위치: ({hm.argmax() % 64}, {hm.argmax() // 64})")
키포인트별 히트맵을 채널 축(channel axis)을 따라 쌓으면 전체 목표 텐서(target tensor)가 됩니다.
Step 2: 작은 키포인트 헤드(keypoint head)
K개의 히트맵 채널을 출력하는 U-Net 형태의 모델입니다.
import torch.nn as nn
import torch.nn.functional as F
class TinyKeypointNet(nn.Module):
def __init__(self, num_keypoints=4, base=16):
super().__init__()
self.down1 = nn.Sequential(nn.Conv2d(3, base, 3, 2, 1), nn.ReLU(inplace=True))
self.down2 = nn.Sequential(nn.Conv2d(base, base * 2, 3, 2, 1), nn.ReLU(inplace=True))
self.mid = nn.Sequential(nn.Conv2d(base * 2, base * 2, 3, 1, 1), nn.ReLU(inplace=True))
self.up1 = nn.ConvTranspose2d(base * 2, base, 2, 2)
self.up2 = nn.ConvTranspose2d(base, num_keypoints, 2, 2)
def forward(self, x):
h1 = self.down1(x)
h2 = self.down2(h1)
h3 = self.mid(h2)
u1 = self.up1(h3)
return self.up2(u1)
입력은 (N, 3, H, W), 출력은 (N, K, H, W)입니다. 손실은 가우시안 목표에 대한 픽셀별 평균제곱오차(per-pixel MSE)입니다.
Step 3: 추론(Inference) — 키포인트 좌표 추출
def heatmap_to_coords(heatmaps):
"""
heatmaps: (N, K, H, W)
returns: (N, K, 2) 이미지 픽셀 좌표(float)
"""
N, K, H, W = heatmaps.shape
hm = heatmaps.reshape(N, K, -1)
idx = hm.argmax(dim=-1)
ys = (idx // W).float()
xs = (idx % W).float()
return torch.stack([xs, ys], dim=-1)
coords = heatmap_to_coords(torch.randn(2, 4, 32, 32))
print(f"좌표: {coords.shape}")
추론은 한 줄로 끝납니다. 부분 픽셀 보정(sub-pixel refinement)이 필요하면 최댓값 위치 주변을 보간합니다.
Step 4: 합성 키포인트 데이터셋(Synthetic keypoint dataset)
단순합니다. 흰 캔버스(canvas)에 네 점을 그리고, 그 점을 예측하도록 학습합니다.
def make_synthetic_sample(size=64):
img = np.ones((3, size, size), dtype=np.float32)
rng = np.random.default_rng()
kps = rng.integers(8, size - 8, size=(4, 2))
for cx, cy in kps:
img[:, cy - 2:cy + 2, cx - 2:cx + 2] = 0.0
hms = np.stack([gaussian_heatmap(size, cx, cy) for cx, cy in kps])
return img, hms, kps
작은 모델이 1분 안에 학습할 수 있을 만큼 쉽습니다.
Step 5: 학습
model = TinyKeypointNet(num_keypoints=4)
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
for step in range(200):
batch = [make_synthetic_sample() for _ in range(16)]
imgs = torch.from_numpy(np.stack([b[0] for b in batch]))
hms = torch.from_numpy(np.stack([b[1] for b in batch]))
pred = model(imgs)
pred = F.interpolate(pred, size=hms.shape[-2:], mode="bilinear", align_corners=False)
loss = F.mse_loss(pred, hms)
opt.zero_grad(); loss.backward(); opt.step()
사용하기
- MediaPipe Pose: 구글(Google)의 운영용 자세 추정기입니다. WebGL과 모바일 런타임(mobile runtime)을 제공하며 10ms 이하의 지연 시간(sub-10ms latency)을 냅니다.
- MMPose(OpenMMLab): 종합적인 연구용 코드베이스(codebase)입니다. 거의 모든 최신(SOTA) 아키텍처와 사전 학습 가중치(pretrained weights)를 제공합니다.
- YOLOv8-pose: 단일 순전파로 처리하는 가장 빠른 실시간 다인 자세(real-time multi-person pose) 모델입니다.
- transformers HumanDPT / PoseAnything: 어떤 객체와 어떤 키포인트 집합에도 대응하려는 개방형 어휘 자세 추정(open-vocabulary pose)의 최신 비전-언어(vision-language) 접근입니다.
산출물 만들기
이 레슨에서는 다음을 만듭니다.
outputs/prompt-pose-stack-picker.md: 지연 시간(latency), 군중 규모(crowd size), 2D/3D 필요에 따라 MediaPipe / YOLOv8-pose / HRNet / ViTPose 중 적절한 스택을 고르는 프롬프트입니다.
outputs/skill-heatmap-to-coords.md: 모든 운영 자세 모델에서 쓰이는 부분 픽셀 히트맵-좌표 변환 루틴(sub-pixel heatmap-to-coordinate routine)을 작성하는 스킬(skill)입니다.
연습문제
- (쉬움) 합성 4점 데이터셋에서 작은 키포인트 모델을 학습합니다. 200 스텝 뒤 예측 키포인트와 실제 키포인트 사이의 평균 L2 오차(mean L2 error)를 보고합니다.
- (중간) 부분 픽셀 보정(sub-pixel refinement)을 추가합니다. 최댓값 위치가 주어졌을 때 주변 픽셀에서 x, y 방향의 1차원 포물선을 맞춥니다. 정수 argmax 대비 정확도 향상을 보고합니다.
- (어려움) 각 이미지에 4점 패턴 인스턴스가 두 개 있는 2인 합성 데이터셋(2-person synthetic dataset)을 만듭니다. 어떤 키포인트가 어떤 인스턴스에 속하는지 예측하는 PAF 기반 상향식 파이프라인을 학습하고 OKS로 평가합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 키포인트(Keypoint) | "랜드마크(landmark)" | 객체 위의 특정한 순서 있는 점(관절, 모서리, 특징) |
| 자세(Pose) | "스켈레톤(skeleton)" | 하나의 인스턴스에 속한 순서 있는 키포인트 집합 |
| 하향식(Top-down) | "검출한 다음 자세 추정" | 사람 검출기와 크롭별 키포인트 모델로 구성된 2단계 파이프라인. 정확도가 가장 높다 |
| 상향식(Bottom-up) | "자세 먼저, 그룹핑은 나중" | 한 번에 모든 키포인트를 예측하고 그룹핑한다. 군중 크기와 무관하게 시간이 거의 일정하다 |
| 히트맵(Heatmap) | "가우시안 목표(Gaussian target)" | 실제 위치에 봉우리(peak)가 있는 키포인트별 H x W 텐서. 선호되는 회귀 목표(regression target) |
| PAF | "부위 친화도 필드(Part Affinity Field)" | 사지(limb) 방향을 나타내는 2채널 단위 벡터 필드. 키포인트를 인스턴스로 묶는 데 쓴다 |
| OKS | "키포인트 IoU" | 객체 키포인트 유사도(Object Keypoint Similarity). COCO 자세 추정 지표 |
| HRNet | "고해상도 네트워크(High-Resolution Net)" | 대표 하향식 키포인트 아키텍처. 고해상도 특징을 끝까지 유지한다 |
더 읽을거리