전체 내역

·article·2026.10.03·13 min read·조회수286

커서를 따라보는 캐릭터 만들기 2편, 193프레임에서 21개 포즈 고르기

AI 영상은 순서가 왕복하고 배경도 그대로 남아 있습니다. PCA로 시선 구간을 나누고, 배경을 지우고, 원본 구도를 유지한 채 스프라이트 시트로 묶은 과정을 코드와 함께 정리했습니다.

AI로 만든 히어로 캐릭터· 2 / 3펼치기
  1. 1. 커서를 따라보는 캐릭터 만들기 1편, 이미지 한 장을 Higgsfield로 영상화하기
  2. 2. 커서를 따라보는 캐릭터 만들기 2편, 193프레임에서 21개 포즈 고르기
  3. 3. 커서를 따라보는 캐릭터 만들기 3편, 잔상 없이 부드럽게 스크러빙하기

지난 편에서 Higgsfield로 8초짜리 캐릭터 영상을 만들었습니다. 그런데 막상 프레임을 넘겨 보니 그대로는 못 쓰겠더라고요. 고개가 왼쪽에 갔다가 정면으로 돌아왔다가 다시 오른쪽으로 가는, 왕복 영상이었기 때문입니다.

이번 글에서는 이 영상을 커서로 스크러빙할 수 있는 스프라이트 시트로 바꾼 과정을 다룹니다. 순서대로 보면 이렇습니다.

  1. 모든 프레임을 추출한다.
  2. 프레임마다 시선 방향을 보고 "왼쪽 끝, 정면, 오른쪽 끝" 순서로 다시 줄 세운다.
  3. 흰 배경을 지운다.
  4. 원본 구도를 유지하면서 한 장의 시트로 묶는다.

1. FFmpeg로 프레임 전부 꺼내기

이 단계는 간단합니다.

mkdir -p frames
ffmpeg -v error -i main.mp4 frames/%03d.png
ls frames | wc -l   # 193

%03d는 001부터 번호를 붙여 줍니다. 코드에서는 0부터 셀 거라서, i번째 프레임은 frames/{i+1:03d}.png가 됩니다. 사소하지만 이것 때문에 한 번 프레임이 하나씩 밀린 적이 있어서 적어 둡니다.

2. 어느 프레임이 어느 쪽을 보는지 찾기

2-1. 영상 순서를 그대로 쓸 수 없는 이유

영상은 정면에서 왼쪽으로 갔다가, 정면으로 돌아오고, 오른쪽으로 갔다가, 다시 정면으로 돌아옵니다. 커서 x 좌표를 영상 시간에 그대로 대응시키면, 커서를 오른쪽으로 옮기는 동안 캐릭터가 왼쪽을 봤다가 정면을 보게 됩니다. 그러니 시간이 아니라 "시선이 어느 쪽인가"를 기준으로 프레임을 다시 골라야 했습니다.

2-2. 얼굴 영역으로 PCA 돌리기

193장을 눈으로 하나씩 분류할 수도 있습니다. 처음엔 실제로 그렇게 하다가, 비슷비슷한 프레임 사이에서 자꾸 헷갈려서 숫자부터 보기로 했습니다.

얼굴 주변만 잘라서 작게 줄이고 주성분 분석(PCA)을 돌리면, 가장 크게 변하는 성분(PC1)이 고개 방향 변화를 꽤 잘 잡아냅니다.

import numpy as np
from PIL import Image
 
def face(i):
    # 1920×1080 프레임에서 얼굴 주변만 잘라 회색조 100×85로 줄인다
    im = Image.open(f"frames/{i + 1:03d}.png").crop((760, 60, 1160, 400))
    return np.asarray(im.convert("L").resize((100, 85)), np.float32).ravel()
 
X = np.stack([face(i) for i in range(193)])
X -= X.mean(axis=0)
U, s, Vt = np.linalg.svd(X, full_matrices=False)
pc1 = U[:, 0] * s[0]                              # 프레임별 첫 번째 주성분 점수
diff = np.abs(np.diff(X, axis=0)).mean(axis=1)    # 이웃 프레임 간 변화량

PC1을 프레임 순서대로 그려 보면 이렇게 나옵니다.

프레임별 PC1 그래프

값이 평평하게 유지되는 구간이 같은 포즈를 유지하는 구간이고, 가파르게 꺾이는 짧은 구간이 고개를 돌리는 전환입니다. diff도 같이 보면 전환 구간에서만 8에서 15 정도로 튀고, 나머지는 0.1에서 0.3 사이에 머뭅니다.

정리해 보면 정면은 0~11, 124~135, 186~192번 프레임이었고, 왼쪽 위를 보는 구간이 20~67과 98~117, 왼쪽 끝이 74~91, 오른쪽이 144~177번이었습니다.

다만 하나 주의할 게 있습니다. PC1은 "각도"가 아니라 그냥 가장 크게 달라지는 축입니다. 그래프에서도 왼쪽 끝 구간이 왼쪽 위 구간보다 오히려 높게 나옵니다. 그래서 구간을 나누는 데까지만 숫자를 쓰고, 각 구간이 실제로 어느 쪽을 보는지는 눈으로 확인했습니다.

구간별 대표 프레임의 얼굴

2-3. 순서 다시 세우기

전환은 두 군데에서 쓸 수 있었습니다. 정면에서 왼쪽으로 가는 0~20번, 정면에서 오른쪽으로 가는 136~146번입니다. 왼쪽 전환은 거꾸로 써서 시트의 맨 앞이 왼쪽 끝이 되게 했습니다.

# 원본 프레임 번호. 같은 포즈가 2프레임씩 반복돼서 포즈가 바뀌는 프레임만 쓴다.
#   80 → 68 : 왼쪽 끝에서 왼쪽 위로 (68~74 전환을 거꾸로)
#   20 → 0  : 왼쪽 위에서 정면으로 (0~20 전환을 거꾸로)
#   136 → 146: 정면에서 오른쪽으로. 0번과 135번은 같은 정면이라 자연스럽게 이어진다.
SEQUENCE = [80, 74, 72, 70, 68, 20, 19, 17, 16, 14, 13, 12, 10, 8, 0,
            136, 138, 140, 142, 144, 146]
IDLE_SOURCE = 0  # 정면(아이들) 포즈

이렇게 고른 21개 포즈가 화면 왼쪽 끝부터 오른쪽 끝까지에 대응합니다. 정면(0번)은 시트에서 15번째 칸에 들어갑니다.

3. 흰 티셔츠 때문에 고생한 배경 제거

블로그가 다크 모드를 지원해서 흰 배경을 그대로 둘 수는 없었습니다. 그런데 이 캐릭터가 흰 티셔츠를 입고 있습니다. "흰색이면 지운다"로 하면 옷까지 같이 날아갑니다.

그래서 테두리와 이어진 배경색 영역만 지우는 방식으로 바꿨습니다. 티셔츠는 검은 윤곽선으로 둘러싸여 있어서 테두리와 연결되지 않거든요.

import cv2
import numpy as np
 
def matte(img):
    a = np.asarray(img.convert("RGB")).astype(np.float32)
    border = np.concatenate([a[0], a[-1], a[:, 0], a[:, -1]])
    bg = np.median(border, axis=0)            # 테두리의 중앙값 = 배경색
    dist = np.abs(a - bg).max(axis=2)          # 픽셀별 배경색과의 거리
 
    # 배경과 비슷한 픽셀 중에서 '테두리와 이어진 덩어리'만 배경으로 본다
    _, labels = cv2.connectedComponents((dist < 10).astype(np.uint8), connectivity=4)
    touching = np.unique(np.concatenate([labels[0], labels[-1], labels[:, 0], labels[:, -1]]))
    bgmask = np.isin(labels, touching[touching > 0])
    ...

여기서 삽질을 하나 했습니다. 처음에는 Pillow의 ImageDraw.floodfill을 테두리 픽셀마다 호출했는데, 이게 순수 파이썬 구현이라 1080p 프레임 21장 처리하는 데 10분이 걸렸습니다. 수정할 때마다 10분씩 기다리다가 cv2.connectedComponents로 바꿨더니 같은 결과가 15초 만에 나왔습니다.

3-1. 경계에 남는 흰 테두리

마스크로 잘라 내기만 하면 윤곽이 계단처럼 깨지고, 흰 배경이 살짝 섞여 있던 경계 픽셀이 다크 모드에서 하얀 테두리로 보입니다. 경계 몇 픽셀에는 배경색과의 거리로 알파를 주고, 섞여 있던 배경색을 계산으로 걷어 냈습니다.

    # 경계 띠에는 배경색과의 거리로 부드러운 알파를 준다
    alpha[edge] = np.clip((dist[edge] - 6) / 40, 0, 1)
 
    # 관측색 P = α·C + (1-α)·bg 이므로, 원래 색 C = bg + (P - bg) / α
    m = edge & (alpha > 0)
    rgb[m] = bg + (a[m] - bg) / alpha[m][:, None]

배경 제거 전후

4. 원본 구도를 지키면서 시트 만들기

4-1. 빈 공간까지 시트에 넣을 필요는 없었습니다

1920×1080 프레임에서 캐릭터가 차지하는 건 가운데 절반 정도입니다. 나머지는 투명한 빈 공간인데, 이걸 그대로 시트에 넣으면 메모리만 먹습니다. 그래서 모든 프레임의 캐릭터를 덮는 공통 영역만 잘라 넣고, 잘라 낸 위치는 메타데이터로 남겼습니다.

boxes = [f.getchannel("A").getbbox() for f in frames]
l = min(b[0] for b in boxes) - PAD
t = min(b[1] for b in boxes) - PAD
r = max(b[2] for b in boxes) + PAD
b = max(b[3] for b in boxes) + PAD
 
meta = {
    "stage": [1920, 1080],          # 원본 영상 크기(구도)
    "box": [l, t, r - l, b - t],    # 원본에서 캐릭터 영역의 x, y, w, h → [497, 38, 912, 1042]
}

화면에서는 16:9 무대를 하나 만들고, 그 안의 원래 자리에 캐릭터를 퍼센트로 배치합니다. 시트는 작게 유지하면서도 화면에서는 원본 구도 그대로 보이게 됩니다.

const [sw, sh] = sprite.stage;
const [x, y, w, h] = sprite.box;
const box = {
  left: `${(x / sw) * 100}%`,
  top: `${(y / sh) * 100}%`,
  width: `${(w / sw) * 100}%`,
  height: `${(h / sh) * 100}%`,
};
 
<div style={{ aspectRatio: `${sw} / ${sh}` }} className="relative">
  <div ref={ref} className="absolute" style={{ ...box, backgroundImage: `url(${sprite.src})` }} />
</div>

4-2. 가로 한 줄에서 격자로 바꾼 이유

처음엔 프레임을 가로 한 줄로 이어 붙였습니다. 그런데 WebP는 한 변 최대 크기가 16383px입니다. 마지막 편에서 중간 프레임을 넣어 62장이 되자 한 줄에 넣으면 프레임당 폭이 264px밖에 안 나왔습니다. 화면에서 캐릭터가 흐려 보일 수밖에 없는 크기라 8열 격자로 바꿨습니다.

62프레임을 담은 8×8 스프라이트 시트

cols, rows = 8, -(-n // 8)                        # 62프레임 → 8×8
assert cols * fw <= 16383 and rows * fh <= 16383  # WebP 한계
sheet = Image.new("RGBA", (cols * fw, rows * fh))
for i, frame in enumerate(seq):
    sheet.paste(frame, ((i % cols) * fw, (i // cols) * fh))
sheet.save("sprite.webp", "WEBP", quality=82, alpha_quality=90, method=6)

4-3. 메모리는 파일 크기가 아니라 디코딩 크기로 봐야 합니다

이 부분은 저도 처음에 놓쳤습니다. 이미지가 쓰는 메모리는 파일 크기가 아니라 디코딩된 크기, 즉 가로 × 세로 × 4바이트입니다. 데스크톱용 시트(5120×5848)는 파일이 2.5MB인데 메모리에는 114MB 정도로 올라갑니다.

데스크톱에서는 괜찮지만, 캐릭터가 작게 보이는 모바일에서까지 114MB를 쓰는 건 낭비였습니다. 그래서 프레임 폭을 360px로 줄인 모바일용 시트(디코딩 36MB)를 따로 만들고, 화면 폭에 따라 하나만 받게 했습니다.

preload(sprite.src,   { as: "image", fetchPriority: "high", media: "(min-width: 768px)" });
preload(sprite.srcSm, { as: "image", fetchPriority: "high", media: "(max-width: 767px)" });
.character-sprite { background-image: var(--sprite); }
@media (max-width: 767px) {
  .character-sprite { background-image: var(--sprite-sm); }
}

마치며

여기까지 하고 나서 스프라이트를 커서에 붙여 봤는데, 새로운 문제가 생겼습니다. 프레임 사이를 섞으면 잔상이 남고, 안 섞으면 툭툭 끊겼습니다. 21개 포즈로는 간격이 너무 컸던 거죠. 이 문제를 광학 흐름으로 푼 이야기는 다음 편에서 이어 가겠습니다.

혹시 AI 영상을 인터랙션 재료로 써 보실 생각이라면, 영상 순서를 믿지 말고 일단 프레임을 전부 꺼내서 한 번 펼쳐 보시길 권합니다. 저도 그 과정에서야 문제가 제대로 보였습니다.

Comments (0)