순수 소프트웨어로 구현하는 비디오 스태빌라이저: GStreamer 파이프라인의 이해
by Justin Kim
순수 소프트웨어로 구현하는 비디오 스태빌라이저: GStreamer 파이프라인의 이해
왜 소프트웨어 스태빌라이저인가
손으로 촬영한 영상은 항상 떨립니다. 손 떨림, 바람, 움직임—대부분의 영상에는 이런 흔들림이 있죠. 비디오 스태빌라이제이션은 이 문제를 푸는 방법인데, 기술에 따라 세 가지로 나뉩니다.
OIS(Optical Image Stabilization)는 렌즈나 센서 자체를 움직여서 보정하고, EIS(Electronic Image Stabilization)는 자이로 센서 데이터를 써서 보정합니다. 둘 다 하드웨어 지원이 필요하고 성능도 좋습니다. 하지만 우리가 항상 이런 고급 센서에 접근할 수 있는 건 아닙니다.
예를 들어 이미 촬영이 끝난 예전 영상, 자이로 없는 저가형 웹캠, 혹은 센서 정보가 전혀 없는 산업용 카메라를 보정해야 한다면? 이 경우 순수하게 영상의 프레임 정보만으로 흔들림을 감지하고 보정하는 소프트웨어 방식(DIS, Digital Image Stabilization)이 유일한 방법입니다.
이 글에서는 이 소프트웨어 방식을 GStreamer로 구현하는 방법을 살펴봅니다.
세 가지 방식 비교:
| 구분 | OIS | EIS | 소프트웨어 (DIS) |
|---|---|---|---|
| 효과 | 최고 | 우수 | 중상 |
| 지연 | 거의 없음 | 낮음 | 수십 프레임 |
| 하드웨어 의존성 | 높음 | 중간 | 없음 |
| 기존 영상 적용 | 불가 | 불가 | 가능 |
| 비용 | 높음 | 중간 | 낮음 |
언제 소프트웨어 방식을 쓸까?
실제로 마주치는 상황들:
- 기존 영상의 후처리: 예전에 찍은 흔들리는 영상을 다시 살려내야 할 때
- 고정 카메라의 흔들림: 야외의 보안 카메라나 중계 카메라가 강풍에 흔들릴 때
- 센서가 없는 장비: 스마트폰처럼 자이로를 쓸 수 없는 산업용 카메라의 영상 분석
핵심 아이디어는 간단합니다. 4K 같은 고해상도로 촬영한 뒤, 1080p 같은 낮은 해상도로 출력하는 겁니다. 그러면 가장자리에 화소의 ‘마진’이 생기는데, 카메라가 흔들릴 때마다 이 마진 안에서 화면을 움직여가며 잘라내면 흔들림이 상쇄됩니다.
과거에는 이런 무거운 연산을 실시간으로 처리하는 것이 불가능했지만, 멀티코어 CPU와 GPU의 성능 향상, 그리고 GStreamer와 OpenCV와 같은 도구들 덕분에 이제는 소프트웨어를 활용한 실시간 스태빌라이제이션이 가능하게 되었습니다.
flowchart LR
A["📷 4K 입력<br/>(3840 × 2160)"]
B["🔍 흔들림 감지<br/>Motion Estimation"]
C["🔄 안정화 변환<br/>Homography 적용"]
D["✂️ Safe Zone Crop<br/>안전 영역 추출"]
E["📺 1080p 출력<br/>(1920 × 1080)"]
A --> B --> C --> D --> E
subgraph margin["← 해상도 마진 활용 구간 (약 15~20% 화소 손실) →"]
B
C
D
end
style A fill:#1d3557,color:#fff,stroke:#0d2035
style E fill:#1b4332,color:#fff,stroke:#0a2218
style B fill:#457b9d,color:#fff,stroke:#1d3557
style C fill:#457b9d,color:#fff,stroke:#1d3557
style D fill:#457b9d,color:#fff,stroke:#1d3557
style margin fill:#fff8e1,stroke:#f9a825,stroke-dasharray:6 4
흔들림의 모델: 3축 기반 접근
카메라는 3차원 공간에서 세 방향의 흔들림이 있을 수 있습니다. 각각을 다음과 같이 부릅니다:
- Yaw(좌우): Y축을 중심으로 회전. 카메라가 옆으로 흔들립니다.
- Pitch(상하): X축을 중심으로 회전. 카메라가 위아래로 기울립니다.
- Roll(회전): Z축(렌즈 중심축)을 중심으로 회전. 카메라가 시계/반시계 방향으로 회전합니다.
이 세 가지 움직임을 모두 추적해야 전체 흔들림을 보정할 수 있습니다. 3차원 회전을 2차원 이미지 평면으로 표현할 때는 호모그래피(Homography) 변환을 사용합니다.
프레임 간 변환의 핵심은 세 가지 요소입니다:
- $C_t$: $t$번째 프레임에서 측정된 누적 모션 행렬 (카메라가 움직인 량)
- $S_t$: $t$번째 프레임에서의 목표 궤적(smooth trajectory). 저주파 모션만 포함.
- $B_t$: 보정 변환. $B_t = S_t \cdot C_t^{-1}$
보정 변환 $B_t$를 각 프레임에 적용하면, 카메라의 누적 모션 $C_t$가 목표 궤적 $S_t$로 변환됩니다.
호모그래피 행렬 $H$는 $3 \times 3$ 행렬로, 2D 이미지 평면에서의 임의의 단사 사영 변환을 나타냅니다:
\[\begin{pmatrix} x' \\ y' \\ 1 \end{pmatrix} = H \begin{pmatrix} x \\ y \\ 1 \end{pmatrix} = \begin{pmatrix} h_{11} & h_{12} & h_{13} \\ h_{21} & h_{22} & h_{23} \\ h_{31} & h_{32} & h_{33} \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \end{pmatrix}\]이 행렬 하나가 3차원 회전, 스케일 변화, 평행이동 등을 모두 포함할 수 있습니다.
저주파 vs 고주파 분리:
측정된 모션 $C_t$ 중에서:
- 저주파(Low Frequency): 카메라 워크(deliberate movement). 3~5프레임에 걸친 부드러운 움직임.
- 고주파(High Frequency): 손떨림(jitter). 1~2프레임 단위의 빠른 진동.
필터(예: Gaussian blur)를 사용해 저주파만 추출하면 $S_t$를 얻을 수 있고, 이를 통해 고주파만 제거할 수 있습니다.
호모그래피의 기하학적 의미
호모그래피 행렬 $H$는 단순히 수치의 집합이 아니라 기하학적 의미를 지닙니다. 이를 분해하면:
\[H = K' R K^{-1}\]여기서:
- $K$: 카메라 내부 파라미터(focal length, principal point).
- $R$: 회전 행렬(3×3).
- $K’$: 변환 후 카메라 파라미터.
실제로는 $H$를 직접 사용하되, 때로는 이를 회전, 스케일, 평행이동 성분으로 분해하기도 합니다. 예를 들어, roll을 과도하게 보정하는 것을 피하기 위해 roll 성분만 추출하여 제한할 수 있습니다.
평활화 전략
목표 궤적 $S_t$를 계산하는 방법은 여러 가지가 있습니다:
- 이동 평균(Moving Average): $S_t = \frac{1}{N} \sum_{i=t-N}^{t} C_i$
- Gaussian 필터: 최근 프레임에 높은 가중치, 과거로 갈수록 낮은 가중치.
- Kalman 필터: 시스템 모델(상수 속도 가정)과 측정치를 결합.
- Spline 보간: 부드러운 곡선으로 궤적 모델링.
Kalman 필터가 가장 정확하지만 계산량이 많고, 이동 평균이 가장 간단하지만 지연이 늘어납니다. 실무에서는 보통 Gaussian 필터 with lookahead(향후 정보 활용)를 선택합니다.
(D2 도식 참조 - 3축 회전(Yaw/Pitch/Roll) 시각화)
Motion Estimation: 움직임의 측정
두 프레임 간의 움직임을 측정하는 방법은 크게 두 가지입니다.
방식 1: 특징점 기반 방식(Feature-Based)
프레임에서 특징적인 점(코너, 엣지 등)을 찾아서:
- 현재와 이전 프레임에서 특징점 검출 (ORB, FAST 등)
- 두 프레임의 특징점을 매칭 (어떤 점이 어디로 이동했는지 찾기)
- RANSAC으로 잘못된 매칭 제거
- 남은 점들로 호모그래피 계산
장점은 빠르고 간단하다는 것입니다. 밝기가 조금 변해도 잘 작동합니다. 단점은 특징점이 없는 영역(밤하늘, 흰 벽 같은)에서 무너질 수 있다는 점입니다.
방식 2: Dense Optical Flow
모든 픽셀에 대해 움직임을 추정합니다.
- Farnebäck: 다항식 계수 기반. 빠름, 중간 정확도.
- DIS (Dense Inverse Search): 실시간 비디오용. Farnebäck보다 빠름.
- RAFT: 최신 신경망 기반. 정확하지만 느림(5~10 fps @ 1080p).
장점: 밀도 높은 정보, 특징점 의존성 없음. 단점: 계산량 많음, 그래픽 카드 필요(RAFT의 경우).
방식 3: 하이브리드 접근
최근 추세는 두 방식을 결합하는 것입니다:
- 조대(Coarse) 단계: Dense optical flow로 전체 그림 파악.
- 세밀(Fine) 단계: Feature-based로 중요 영역 정밀화.
이렇게 하면 특징점이 없는 영역도 처리하고, 계산량도 줄일 수 있습니다.
호모그래피 vs 어파인(Affine) 변환
간단한 경우(평행이동, 회전, 스케일)에는 Affine 변환으로 충분합니다:
\[\begin{pmatrix} x' \\ y' \end{pmatrix} = \begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} + \begin{pmatrix} e \\ f \end{pmatrix}\]매개변수는 6개(a, b, c, d, e, f)이므로 3개 점이 필요합니다. 호모그래피는 원근 변환까지 포함하므로 8개 매개변수이고 4개 점이 필요합니다.
실무에서는 호모그래피를 기본으로 사용하되, 카메라가 렌즈 중심축에 대해 큰 각도로 회전하지 않는 한 근사적으로 Affine처럼 동작합니다.
성능 팁: 다운샘플 추정
4K(3840×2160) 영상에서 매 프레임마다 호모그래피를 추정하는 것은 느립니다. 대신:
- 원본을 540p로 다운샘플.
- 540p에서 호모그래피 $H_{540}$ 추정.
- $H_{4K} = \text{scale}(3840/540) \cdot H_{540}$ 로 스케일 조정.
- $H_{4K}$를 원본 4K 프레임에 적용.
이 방식으로 계산량을 대략 50배 줄일 수 있습니다.
실패 케이스와 Fallback
호모그래피 추정이 실패할 수 있습니다:
- 장면 컷(Scene cut): 이전 프레임과 완전히 다른 내용.
- 대규모 움직임(Fast pan): 특징점이 화면을 벗어남.
- 움직이는 피사체: 배경 특징점만 감지하여 잘못된 추정.
이 경우 confidence score를 계산하고, 신뢰도가 낮으면:
- 직전 프레임의 모션 재사용.
- 또는 항등 변환(Identity transform) 적용 (보정 없음).
신뢰도 점수 계산 방법:
confidence = (RANSAC 내부점 수) / (전체 매칭점 수)
예를 들어, 1000개 점 중 800개가 호모그래피를 만족하면 confidence = 0.8입니다. 보통 임계값(threshold):
confidence > 0.7: 신뢰 가능, 호모그래피 적용.0.3 < confidence <= 0.7: 경고, 큰 움직임 감지, Fallback 고려.confidence <= 0.3: 신뢰 불가, Fallback (이전 모션 또는 항등 변환).
코드 샘플 1: Python + OpenCV로 호모그래피 추정하기
import cv2
import numpy as np
def estimate_homography(prev_frame, curr_frame, downsample_factor=4):
"""두 프레임 간 흔들림을 호모그래피로 구합니다.
4K 프레임 전체를 매번 분석하면 너무 느리므로, 해상도를 줄여서 빠르게 계산합니다.
"""
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
curr_gray = cv2.cvtColor(curr_frame, cv2.COLOR_BGR2GRAY)
# 다운샘플
h, w = prev_gray.shape
down_h, down_w = h // downsample_factor, w // downsample_factor
prev_down = cv2.resize(prev_gray, (down_w, down_h))
curr_down = cv2.resize(curr_gray, (down_w, down_h))
# ORB 특징점 검출 및 매칭
orb = cv2.ORB_create(nfeatures=5000)
kp1, des1 = orb.detectAndCompute(prev_down, None)
kp2, des2 = orb.detectAndCompute(curr_down, None)
if des1 is None or des2 is None or len(kp1) < 4 or len(kp2) < 4:
return np.eye(3), 0.0
# Brute-force 매칭
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1, des2)
if len(matches) < 4:
return np.eye(3), 0.0
# 매칭점 추출
src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)
dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)
# RANSAC으로 호모그래피 추정
H_down, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
if H_down is None:
return np.eye(3), 0.0
# 신뢰도 계산: RANSAC 내부점 비율
inlier_count = np.sum(mask)
confidence = min(1.0, inlier_count / len(matches))
# 스케일 조정: 다운샘플 → 원본 해상도
scale_matrix = np.array([
[downsample_factor, 0, 0],
[0, downsample_factor, 0],
[0, 0, 1]
])
H = np.linalg.inv(scale_matrix) @ H_down @ scale_matrix
return H, confidence
(D3 도식 참조 - Feature 검출→매칭→이상치 제거→호모그래피 파이프라인)
flowchart TD
A["🎬 Frame t"]
B["🎬 Frame t+1"]
C["Step 1 · 두 프레임 수신"]
D["Step 2 · Feature Detection<br/>🔎 ORB / FAST 특징점 검출"]
E["Step 3 · Feature Matching<br/>🔗 KNN 매칭 (k = 2, ratio test)"]
F["Step 4 · Outlier Rejection<br/>🎯 RANSAC 이상치 제거"]
G["Step 5 · Homography 계산<br/>📐 최소자승법 (DLT 알고리즘)"]
H["✅ H 행렬 출력<br/>3 × 3 투시 변환"]
A --> C
B --> C
C --> D --> E --> F --> G --> H
style A fill:#6c3483,color:#fff,stroke:#5b2c6f
style B fill:#6c3483,color:#fff,stroke:#5b2c6f
style C fill:#34495e,color:#fff,stroke:#2c3e50
style D fill:#1a5276,color:#fff,stroke:#154360
style E fill:#1a5276,color:#fff,stroke:#154360
style F fill:#784212,color:#fff,stroke:#6e2f1a
style G fill:#1a5276,color:#fff,stroke:#154360
style H fill:#145a32,color:#fff,stroke:#0e3d22
GStreamer 파이프라인 구현
이제 실제로 이 스태빌라이저를 GStreamer로 구현해봅시다. GStreamer는 오픈소스 멀티미디어 프레임워크로, 작은 부품(Elements)들을 레고처럼 조립해서 영상 처리 파이프라인을 만듭니다.
전체 구조는 이렇습니다:
입력(카메라나 파일) → 디코딩 → 분기 → [모션 추정 경로] + [변환 경로] → 합성 → 인코딩 → 출력
분기점에서 두 가지 경로로 나뉩니다:
- 추정 경로: 빠른 계산을 위해 해상도를 낮춰서 호모그래피 계산
- 변환 경로: 원본 해상도에 계산된 호모그래피를 적용하고 자르기
두 경로가 다시 만나서 최종 영상을 만듭니다.
상세 구조:
graph LR
SRC["📹 v4l2src<br/>또는 filesrc"]
PARSE["h264parse"]
DEC["avdec_h264<br/>디코더"]
TEE["⬡ tee<br/>분기"]
subgraph b1["Branch 1 — 모션 추정 경로 (다운샘플)"]
SCALE["videoscale<br/>720p 다운샘플"]
VC1["videoconvert<br/>RGB 변환"]
STAB["⚙️ gststabilizer<br/>Motion Estimation"]
META["H 행렬<br/>GstMeta 첨부"]
end
subgraph b2["Branch 2 — 변환·Crop 경로 (풀 해상도)"]
QUEUE["queue<br/>버퍼 동기화"]
VC2["videoconvert"]
WARP["✂️ gstwarpcrop<br/>Warp + Crop → 1920×1080"]
end
COMP["compositor<br/>합성"]
ENC["x264enc<br/>인코더"]
MUX["mp4mux"]
SINK["📁 filesink<br/>출력"]
SRC --> PARSE --> DEC --> TEE
TEE -->|"src_0"| SCALE
SCALE --> VC1 --> STAB --> META
TEE -->|"src_1"| QUEUE
QUEUE --> VC2 --> WARP
META -.->|"H 행렬 GstMeta"| WARP
META --> COMP
WARP --> COMP
COMP --> ENC --> MUX --> SINK
style SRC fill:#2c3e50,color:#fff,stroke:#1a252f
style PARSE fill:#34495e,color:#fff,stroke:#2c3e50
style DEC fill:#34495e,color:#fff,stroke:#2c3e50
style TEE fill:#e67e22,color:#fff,stroke:#d35400
style SCALE fill:#5d6d7e,color:#fff,stroke:#4a5568
style VC1 fill:#5d6d7e,color:#fff,stroke:#4a5568
style STAB fill:#2980b9,color:#fff,stroke:#1a5276
style META fill:#27ae60,color:#fff,stroke:#1e8449
style QUEUE fill:#5d6d7e,color:#fff,stroke:#4a5568
style VC2 fill:#5d6d7e,color:#fff,stroke:#4a5568
style WARP fill:#2980b9,color:#fff,stroke:#1a5276
style COMP fill:#8e44ad,color:#fff,stroke:#6c3483
style ENC fill:#34495e,color:#fff,stroke:#2c3e50
style MUX fill:#34495e,color:#fff,stroke:#2c3e50
style SINK fill:#2c3e50,color:#fff,stroke:#1a252f
데이터 흐름 범례: 실선(→) = 비디오 프레임 흐름 / 점선(-.→) = H 행렬 메타데이터 채널
커스텀 element:gststabilizer(모션 추정),gstwarpcrop(워프·크롭)
왜 이러한 구조인가?
GStreamer의 tee(분배) 요소는 한 개의 입력을 여러 출력으로 복제합니다. 우리는 이를 활용하여:
- 저해상도 추정 분기: 540p로 다운스케일하여 빠르게 호모그래피 계산.
- 버퍼 분기: 추정이 끝날 때까지 버퍼에 프레임 저장.
- 합성 단계: 추정된 호모그래피를 원본 프레임에 적용.
이 구조의 장점:
- 병렬 처리: 추정과 버퍼링이 동시에 진행.
- 유연성: queue의 깊이를 조정하여 지연과 품질의 트레이드오프 제어.
- 확장성: YOLO/SAM 등 추가 분석을 쉽게 삽입 가능.
커스텀 Element 설계
GStreamer의 강점은 커스텀 Element를 작성할 수 있다는 점입니다. 두 가지 요소를 정의합니다:
gststabilizer:- 입력: 프레임 스트림
- 처리: 호모그래피 추정, 부드러운 궤적 계산
- 출력: 동일 프레임 + GStreamer metadata(
GstStabilizerMotionMeta)로 $H$ 행렬과 confidence 첨부 - 상태: 이전 프레임 저장, 누적 모션 추적
gstwarpcrop:- 입력: 프레임 +
GstStabilizerMotionMeta - 처리: 호모그래피 변환 적용 (
cv2.warpPerspective), 안전 영역만 crop - 출력: 안정화된 출력 프레임
- 입력: 프레임 +
Latency 전략
실시간성과 품질 사이의 트레이드오프가 있습니다:
- Lookahead: 현재 프레임의 보정을 계산하기 위해 향후 N프레임을 봅니다. N이 크면 더 좋은 궤적 계산 가능하지만 지연 증가.
- N=0 (Causality only): 지연 최소, 품질 낮음.
- N=5: 대략 5프레임(60fps 기준 83ms) 지연, 품질 우수.
- N=15: 대략 15프레임(250ms) 지연. 대부분의 실시간 애플리케이션에는 부담.
- Queue 깊이:
gststabilizer이후 queue 크기를 조정하여 버퍼링 제어.
Crop Window 산수
4K 원본을 1080p로 출력할 때의 crop 계산:
원본 크기: 3840 × 2160
출력 크기: 1920 × 1080 (1080p)
스케일 비율: 3840 / 1920 = 2
따라서 원본에서 4×4 픽셀은 출력에서 2×2 픽셀
카메라 떨림 마진(예: 10%):
- 원본 너비 마진: 3840 × 0.1 = 384 픽셀
- 원본 높이 마진: 2160 × 0.1 = 216 픽셀
안전 영역(Safe Zone):
- x: [192, 3648] (양쪽 192px 제거)
- y: [108, 2052] (위아래 108px 제거)
- 크기: 3456 × 1944
이 안전 영역을 1080p로 스케일:
- 최종 출력: 1920 × 1080
호모그래피로 인한 왜곡으로 프레임의 모서리가 손실될 수 있으므로, 더 보수적인 마진(예: 15%)을 설정하는 것이 일반적입니다.
마진 선택의 트레이드오프
마진이 클수록:
- 더 많은 떨림 보정 가능 (큰 움직임 수용).
- 하지만 최종 해상도가 더 떨어짐.
마진이 작을수록:
- 최종 해상도 유지.
- 하지만 떨림 보정 범위 제한.
실무 권장값:
- 일반: 10~15% (가장 흔함).
- 높은 안정성 필요: 15~20% (드론, 자동차 촬영).
- 해상도 우선: 5~10% (정적 카메라 또는 약한 떨림).
예를 들어, 마진 10%이면 3840×2160 (4K)이 3456×1944 안전 영역이 되고, 이를 1920×1080 (1080p)로 스케일하면 대략 1.8배 다운샘플링으로 약간의 선명도 손실이 발생합니다.
(D5 도식 참조 - Crop window 시각화: 입력 프레임 + safe zone + 출력)
코드 샘플 2: gst-launch-1.0 Prototyping
# USB 카메라를 안정화하여 H.264로 인코딩하고 파일로 저장하는 예
gst-launch-1.0 -e \
v4l2src device=/dev/video0 ! \
video/x-raw,width=3840,height=2160,framerate=30/1 ! \
videoscale ! video/x-raw,width=1920,height=1080 ! \
gststabilizer lookahead=5 downsample=4 ! \
gstwarpcrop margin=0.15 ! \
x264enc ! \
h264parse ! \
mp4mux ! \
filesink location=stabilized.mp4
코드 샘플 3: Python + gi.repository GStreamer 바인딩
import gi
gi.require_version('Gst', '1.0')
from gi.repository import Gst, GLib
import cv2
import numpy as np
class GStreamerStabilizer:
"""
GStreamer 파이프라인에 통합되는 스태빌라이저 클래스.
gststabilizer 커스텀 element의 골격.
"""
def __init__(self, downsample=4, lookahead=5):
self.downsample = downsample
self.lookahead = lookahead
self.prev_frame = None
self.motion_history = [] # 모션 벡터 히스토리
self.smooth_trajectory = None # S_t
def process_frame(self, frame):
"""
한 프레임을 처리합니다.
Args:
frame: (H, W, 3) BGR numpy array
Returns:
H: 3x3 호모그래피 행렬
confidence: 신뢰도
"""
if self.prev_frame is None:
self.prev_frame = frame
return np.eye(3), 1.0
# 호모그래피 추정 (§3의 코드 샘플 1 사용)
H, confidence = self._estimate_homography(self.prev_frame, frame)
# 모션 히스토리 저장
self.motion_history.append((H, confidence))
if len(self.motion_history) > self.lookahead:
self.motion_history.pop(0)
# 부드러운 궤적 계산 (Gaussian 필터)
# 실제 구현에서는 Kalman 필터나 더 정교한 방식 사용
smoothed_H = self._compute_smooth_trajectory(H)
self.prev_frame = frame.copy()
return smoothed_H, confidence
def _estimate_homography(self, prev, curr):
"""호모그래피 추정. 코드 샘플 1의 함수 사용."""
# (여기서는 생략, 실제로는 코드 샘플 1의 함수 호출)
pass
def _compute_smooth_trajectory(self, H):
"""
현재 호모그래피와 히스토리를 이용해 부드러운 궤적을 계산합니다.
간단한 예: 최근 N프레임의 평균.
"""
if len(self.motion_history) == 0:
return H
# 호모그래피를 행렬로 축적할 수 없으므로,
# 실제로는 회전/스케일/평행이동 성분을 분해 후 평균
# 여기서는 개념 시연용으로 직접 평균 (부정확함)
avg_H = np.mean([h for h, _ in self.motion_history], axis=0)
return avg_H
def warp_and_crop(self, frame, H, margin=0.15):
"""
호모그래피를 적용하고 마진만큼 crop합니다.
Args:
frame: 입력 프레임
H: 호모그래피 행렬
margin: 마진 비율 (0.15 = 15%)
Returns:
warped_frame: 변환 및 crop된 프레임
"""
h, w = frame.shape[:2]
# 호모그래피 변환 적용
warped = cv2.warpPerspective(frame, H, (w, h))
# Crop 계산
crop_x = int(w * margin / 2)
crop_y = int(h * margin / 2)
crop_w = w - 2 * crop_x
crop_h = h - 2 * crop_y
cropped = warped[crop_y:crop_y + crop_h, crop_x:crop_x + crop_w]
# 다시 원본 크기로 스케일 (또는 고정된 출력 해상도)
output_h, output_w = h // 2, w // 2 # 예: 4K → 1080p
final = cv2.resize(cropped, (output_w, output_h))
return final
# GStreamer 파이프라인 예
def create_stabilizer_pipeline():
Gst.init(None)
pipeline = Gst.Pipeline.new("stabilizer-pipeline")
# 요소 생성
source = Gst.ElementFactory.make("v4l2src", "source")
decoder = Gst.ElementFactory.make("decodebin", "decoder")
encoder = Gst.ElementFactory.make("x264enc", "encoder")
sink = Gst.ElementFactory.make("filesink", "sink")
if not all([source, decoder, encoder, sink]):
print("Failed to create elements")
return None
# 속성 설정
source.set_property("device", "/dev/video0")
sink.set_property("location", "stabilized.mp4")
# 파이프라인에 추가
pipeline.add(source, decoder, encoder, sink)
# 링크 (실제로는 더 많은 캡스 필터링 필요)
source.link(decoder)
decoder.link(encoder)
encoder.link(sink)
return pipeline
AI 기반 고급 보정
기본 호모그래피 기반 스태빌라이저도 효과적이지만, AI 기반 보정으로 한 단계 더 나아갈 수 있습니다.
조명 변화 보정
카메라가 떨릴 때 장면의 밝기가 급격히 변할 수 있습니다. Auto-exposure(자동 노출) 조정이 느려서 일부 프레임이 너무 밝거나 어두워집니다. 이를 조명 flicker라고 부릅니다.
해결 방법:
- CNN 기반 denoising: 밝기 변화 패턴을 학습한 신경망으로 평활화(smoothing).
- LUT(Lookup Table) 기반: 프레임 간 밝기 차이를 분석하여 히스토그램 매칭.
# 간단한 예: 프레임 간 평균 밝기 차이로 보정
def compensate_exposure(frame, prev_frame, alpha=0.8):
curr_brightness = np.mean(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
prev_brightness = np.mean(cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY))
# 지수 이동 평균으로 부드럽게 보정
target_brightness = alpha * prev_brightness + (1 - alpha) * curr_brightness
adjustment = target_brightness / (curr_brightness + 1e-5)
return cv2.convertScaleAbs(frame, alpha=adjustment, beta=0)
피사체 인식 기반 안정화
일반적인 스태빌라이저는 모든 픽셀을 동등하게 취급합니다. 하지만 메인 피사체(main subject)가 있다면, 그 영역을 우선적으로 안정화할 수 있습니다.
- YOLO(You Only Look Once): 빠른 물체 감지. 실시간 비디오에 적합.
- SAM(Segment Anything Model): 프롬프트 기반 세분화. 특정 피사체만 추출.
알고리즘:
- YOLO로 메인 객체(사람, 자동차 등) 감지.
- 해당 BBox(Bounding Box) 내의 특징점에 더 높은 가중치.
- 가중치가 반영된 호모그래피 추정.
# 의사 코드: 가중치 기반 호모그래피
def weighted_homography(src_pts, dst_pts, weights):
"""
가중치가 적용된 RANSAC.
"""
# 실제로는 weighted RANSAC 또는 Huber loss 사용
H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
# 가중치는 outlier rejection에 반영
return H
Edge Inpainting
호모그래피 변환과 crop으로 인해 프레임의 가장자리가 손실됩니다. 이 부분을 복원(inpainting)할 수 있습니다.
- 전통적: 인접 픽셀 복제 또는 선형 보간.
- AI 기반: 조건부 생성 모델(Conditional GAN) 또는 확산 모델(Diffusion Model)로 자연스러운 콘텐츠 생성.
def inpaint_edges(frame, mask, kernel_size=5):
"""
마스크된 영역을 Telea 알고리즘으로 inpaint합니다.
"""
return cv2.inpaint(frame, mask, kernel_size, cv2.INPAINT_TELEA)
GStreamer 통합
AI 모듈을 GStreamer에 통합하는 방법:
- gst-nvinfer (NVIDIA DeepStream): NVIDIA GPU에서 추론 실행. 가장 빠름.
- ONNX Runtime 커스텀 element: 다양한 백엔드(CPU, GPU) 지원. 더 유연.
- TensorFlow Lite: 경량 모델용.
⚠️ GPU 기반 추론은 별도 글에서 자세히 다룰 예정입니다. 여기서는 CPU 기반 처리에 집중합니다.
실제 구현 고려사항
AI 모듈을 추가할 때 주의할 점:
- 모델 크기: 경량 모델(MobileNet, ShuffleNet) 우선. 1080p 프레임당 10~50ms 내로 제약.
- 배치 처리: 1개 프레임씩이 아니라 4~8개 프레임을 모아 처리하여 GPU 활용도 증가.
- 비동기 처리: 메인 스트림은 이전 프레임 결과를 사용하고, 추론은 별도 스레드.
- 폴백(Fallback): 모델 오류 시 기본 호모그래피로 복귀.
예를 들어, YOLO 기반 피사체 인식을 추가하려면:
# 의사 코드
class AIStabilizerModule:
def __init__(self):
self.yolo_model = load_yolo_model("yolov8n.pt") # nano 버전
self.prev_features = None
def process(self, frame):
# YOLO 추론 (GPU 또는 CPU)
detections = self.yolo_model(frame)
# 메인 피사체 추출 (신뢰도 > 0.5)
main_objects = [d for d in detections if d.confidence > 0.5]
if main_objects:
# BBox 내 특징점에 높은 가중치
weights = self._compute_weights(frame.shape, main_objects)
H = self._weighted_homography(frame, weights)
else:
# 폴백: 기본 호모그래피
H = self._default_homography(frame)
return H
이 방식으로 사람이 많은 환경에서도 주요 피사체(예: 강사, 발표자)의 안정성을 우선할 수 있습니다.
성능 트레이드오프
AI 보정을 추가하면 다음과 같은 트레이드오프가 발생합니다:
| 측면 | CPU 기본 | AI 기반 |
|---|---|---|
| 정확도 | 중간 | 높음 |
| 지연(Latency) | ~50ms | ~200ms+ |
| 리소스 | 낮음 | 매우 높음 |
| 실시간성 | 1080p@30fps 가능 | 1080p@5fps (CPU) |
(D6 도식 참조 - 기본 파이프라인 + AI 모듈 추가 시 변경 토폴로지)
graph LR
SRC["📹 v4l2src"]
DEC["avdec_h264<br/>디코더"]
TEE["⬡ tee"]
STAB["⚙️ gststabilizer<br/>모션 추정"]
WARP["✂️ gstwarpcrop<br/>Warp + Crop"]
COMP["compositor<br/>합성"]
ENC["x264enc<br/>mp4mux"]
SINK["📁 filesink"]
EXP["💡 gstexposuresmoothing<br/>조명 변화 보정"]
SAL["👁️ gstsaliencydetector<br/>YOLO / SAM2 피사체 감지"]
EDGE["🖌️ gstedgeinpainter<br/>경계 복원 인페인팅"]
subgraph aimodules["🤖 AI 확장 모듈 (심화 편 예정)"]
EXP
SAL
EDGE
end
SRC --> DEC --> TEE
TEE -->|"추정 경로"| STAB
TEE -->|"변환 경로"| WARP
STAB -.->|"H 행렬"| WARP
STAB --> COMP
WARP --> EXP --> SAL --> EDGE --> COMP
COMP --> ENC --> SINK
style SRC fill:#2c3e50,color:#fff,stroke:#1a252f
style DEC fill:#34495e,color:#fff,stroke:#2c3e50
style TEE fill:#e67e22,color:#fff,stroke:#d35400
style STAB fill:#27ae60,color:#fff,stroke:#1e8449
style WARP fill:#27ae60,color:#fff,stroke:#1e8449
style COMP fill:#8e44ad,color:#fff,stroke:#6c3483
style ENC fill:#34495e,color:#fff,stroke:#2c3e50
style SINK fill:#2c3e50,color:#fff,stroke:#1a252f
style EXP fill:#1a5276,color:#fff,stroke:#154360
style SAL fill:#1a5276,color:#fff,stroke:#154360
style EDGE fill:#1a5276,color:#fff,stroke:#154360
style aimodules fill:#d6eaf8,stroke:#2980b9,stroke-dasharray:6 4
색상 범례:
🟢 녹색 박스 = 기본 CPU element (gststabilizer,gstwarpcrop)
🔵 파란 박스 = AI 가속 element (별도 글에서 상세 설명 예정)
🟠 주황 = tee 분기점 / 🟣 보라 = compositor
통합 및 성능 고려사항
최종 한 줄 파이프라인
지금까지의 내용을 종합하면:
gst-launch-1.0 \
v4l2src device=/dev/video0 ! \
videoconvert ! \
gststabilizer lookahead=5 downsample=4 ! \
gstwarpcrop margin=0.15 ! \
x264enc bitrate=5000 ! \
mp4mux ! \
filesink location=stabilized.mp4
이 한 줄이 다음을 수행합니다:
- USB 카메라에서 영상 획득.
- 호모그래피 추정으로 모션 분석.
- 부드러운 궤적 계산(lookahead=5프레임).
- 호모그래피 변환 적용 및 15% 마진으로 crop.
- H.264로 인코딩하여 MP4로 저장.
성능 고려사항 (정성적)
- CPU 부하:
- 호모그래피 추정: 프레임당 10~50ms (해상도와 특징점 수에 따라).
- 멀티스레드 활용: 추정과 인코딩을 별도 스레드에서 병렬 처리.
- 다운샘플링: 계산량을 N배 감소 (우리 예에서 N=16).
- 최적화: SIMD 명령어(SSE, AVX) 활용으로 추가 5~10배 가속 가능.
- 메모리 사용:
- 버퍼 크기: lookahead N프레임 + queue 깊이.
- 4K@30fps로 5프레임 lookahead = 대략 600MB (YUV420 기준).
- 임베디드 시스템에서는 lookahead를 0~3으로 제한 권장.
- 누수 방지: GStreamer의
gst-play도구로 메모리 프로파일링.
- 실시간성:
- 목표: 프레임 처리 시간 < 33ms (30fps 기준).
- 방법: 호모그래피 추정을 비동기로 수행, 메인 렌더링은 버퍼된 이전 결과 사용.
- 측정: GStreamer의
GST_DEBUG=3환경 변수로 성능 로그 수집.
실측 가능성
실제 성능은 다음 변수들에 따라 결정됩니다:
| 변수 | 저사양 | 중사양 | 고사양 |
|---|---|---|---|
| CPU | Celeron | i5 | i9/Ryzen9 |
| 코어 수 | 2 | 4~6 | 8+ |
| 해상도 | 720p | 1080p | 4K |
| 특징점 수 | 500 | 2000 | 5000 |
| 처리 시간 | 50~100ms | 10~30ms | 5~15ms |
벤치마크 부재
원래 계획에서는 성능 벤치마크 표를 포함하려 했으나, 실제 측정 데이터가 없으므로 정성적 설명으로 대신합니다. 실측 성능은 다음 요인에 크게 좌우됩니다:
- 카메라 해상도 및 프레임 레이트.
- CPU 모델 및 코어 수.
- 호모그래피 추정 방식(Feature-based vs Dense optical flow).
- 버퍼링 및 큐 설정.
확장 가능성
이 기본 구조에서 다음으로 확장할 수 있습니다:
- GPU 가속: CUDA 기반 호모그래피 추정으로 10배 속도 향상. (별도 글 예정)
- 멀티스레딩: 추정과 렌더링을 분리하여 지연 최소화.
- 6DoF 모션: 3축 회전뿐만 아니라 깊이 정보(Depth) 활용.
- End-to-End 학습: 전체 파이프라인을 신경망으로 학습(LSTM, Transformer).
- 분산 처리: 고해상도 라이브 스트림을 여러 머신에서 처리.
오픈소스 참고
스태빌라이저 구현 시 참고할 만한 프로젝트들:
- vid.stab (FFmpeg): 전통적 Feature-based 스태빌라이저. FFmpeg의 vidstabdetect/vidstabtransform 필터로 사용.
- OpenCV의
cv2.warpPerspective: 호모그래피 변환의 기준 구현. - GStreamer gst-opencv: OpenCV와 GStreamer 통합.
- RAFT (Optical Flow): 최신 광학 흐름 추정. PyTorch 기반.
30분 안에 직접 돌려보기
최소한의 스태빌라이저를 직접 실행해보려면:
# 1. 설치
pip install opencv-python opencv-contrib-python
sudo apt-get install gstreamer1.0-tools
# 2. Python 스크립트로 동영상 처리
python stabilize.py input.mp4 output.mp4
# 3. GStreamer로 실시간 카메라
gst-launch-1.0 v4l2src ! videoconvert ! autovideosink
실제 코드는 몇십 줄 이내로 작성 가능합니다. 이미지 처리의 기초(특징점 매칭, 호모그래피)를 이해하면, 나머지는 OpenCV와 GStreamer API를 따라가기만 하면 됩니다.
결론
순수 소프트웨어 스태빌라이저는 하드웨어에 의존하지 않으면서도 효과적인 흔들림 제거를 제공합니다. 호모그래피 기반의 기하학적 모델은 간단하면서도 대부분의 실제 상황(손떨림, 풍선 진동, 드론 요동)에 잘 작동합니다.
참고 도식
- D1: 개념도 (Mermaid flowchart) - OIS/EIS/순수 SW 비교, 사용 시나리오
- D2: 3축 회전 (SVG 인라인) - Yaw/Pitch/Roll 시각화
- D3: Motion Estimation 파이프라인 (Mermaid flowchart) - Feature 검출→매칭→RANSAC→호모그래피
- D4: GStreamer 메인 파이프라인 (Mermaid graph) - tee/queue/커스텀 element 토폴로지
- D5: Crop window 계산 시각화 (SVG 인라인) - 입력 프레임 + safe zone + 출력 영역
- D6: AI 모듈 추가 토폴로지 (Mermaid graph) - 기본 파이프라인 + CNN/YOLO 분기
Subscribe via RSS
Comments