TECH NOTE · AUTOMATION OPS
AI가 그린 캐릭터는 동작마다 크기가 다릅니다. 머리 폭을 기준으로 축소만 허용해 맞추고 접지선을 다시 잡는 방법입니다.
같은 캐릭터인데 동작마다 몸집이 다르다
이미지 생성 모델로 캐릭터의 여러 동작을 만들었다. 서기, 걷기, 앉기, 오르기, 낙하산 타기까지 35가지쯤 된다.
한 장씩 보면 다 괜찮았다. 붙여서 애니메이션으로 돌리니 동작이 바뀔 때마다 캐릭터가 커졌다 작아졌다 했다.
당연한 결과였다. 각 그림을 따로 요청했으니 모델이 매번 다른 크기로 그린다. 사람도 캐릭터를 여러 번 그리면 크기가 조금씩 달라진다.
무엇을 기준으로 맞출 것인가
캔버스 크기는 이미 같았다. 512×512 격자에 하나씩 들어 있다.
문제는 그 안에서 캐릭터가 차지하는 비율이다. 기준이 필요했는데, 후보가 여럿이었다.
| 기준 | 문제 |
|---|---|
| 전체 높이 | 앉은 자세와 선 자세는 원래 높이가 다르다 |
| 폭 | 팔을 벌리면 달라진다 |
| 면적 | 자세에 따라 크게 변한다 |
| 머리 크기 | 자세와 무관하다 |
머리로 정했다. 사람이 캐릭터 크기를 인지할 때 실제로 쓰는 단서이기도 하다. 만화에서 “몇 등신”이라고 말하는 것과 같은 이야기다.
앉든 서든 누워 있든 머리 크기는 안 변한다. 자세에 불변인 특징을 기준으로 잡는 게 핵심이다.
재는 방법은 알파 채널을 쓴다. 캐릭터의 가장 위쪽 불투명 픽셀부터 아래로 훑으면서, 가로 폭이 가장 넓어지는 지점이 머리 최대 폭이다.
def head_width(cell, alpha_floor=8, scan_rows=140):
"""셀 위쪽 일부만 훑어 머리 최대 가로 폭을 구한다."""
pixels = cell.load()
width, height = cell.size
top = next(
(y for y in range(height)
if any(pixels[x, y][3] > alpha_floor for x in range(width))),
None,
)
if top is None:
return 0 # 빈 프레임
widest = 0
for y in range(top, min(top + scan_rows, height)):
xs = [x for x in range(width) if pixels[x, y][3] > alpha_floor]
if xs:
widest = max(widest, xs[-1] - xs[0] + 1)
return widest
scan_rows로 위쪽만 보는 게 중요하다. 셀 전체를 훑으면 팔을 벌린 자세에서 어깨 폭이 머리보다 넓게 잡힌다.
확대는 안 한다
기준을 정한 다음 방향을 정해야 했다. 큰 것을 줄일 것인가, 작은 것을 키울 것인가.
축소만 하기로 했다. 확대하면 화질이 깨진다. 원본에 없는 픽셀을 만들어 내야 하고, 선이 뭉개진다.
그래서 가장 작게 그려진 것에 전부 맞췄다.
기준: window_edge_climb_up (아틀라스에서 가장 작은 정당한 묘사)
= idle 그림의 약 0.57배
정책: shrink-only (축소만 허용)
결과는 이랬다.
31개 상태 → 0.57배 일괄 적용
wall_climb_start → 0.80 (얼굴 폭 실측 기준)
wall_climb_mid → 1.0 상한 (이미 작음)
attempt-8 walk → 0.87
“가장 작은 정당한 묘사” 라는 표현을 쓴 이유가 있다. 개중에는 그냥 잘못 그려져서 작은 것도 있었다. 그건 기준이 될 수 없다. 제대로 그려진 것 중 가장 작은 것을 골라야 한다.
전부 같은 배율은 아니다
31개는 일괄 0.57배로 됐지만 셋은 따로 계산했다.
벽을 오르는 자세들이다. 이 그림들은 원래부터 작게 그려져 있었다. 여기에 0.57을 또 곱하면 지나치게 작아진다.
그래서 얼굴 폭을 실제로 재서 개별 배율을 냈다.
wall_climb_start: 0.80 (face-width measurement)
wall_climb_mid: 1.0 상한 (더 줄이지 않음)
일괄 적용으로 대부분을 처리하고 예외만 손으로 잰다. 35개를 전부 재는 것은 시간이 아깝고, 전부 일괄로 하면 몇 개가 망가진다.
크기만 맞으면 끝이 아니다
여기서 한 번 더 걸렸다. 크기를 맞췄는데도 애니메이션이 튀었다.
캐릭터를 축소하면 셀 안에서 어디를 기준으로 줄이느냐에 따라 발 위치가 달라진다. 중심 기준으로 줄이면 발이 위로 올라온다.
캐릭터는 바닥에 서 있어야 하므로 발 위치가 고정돼야 한다. 그래서 축소 후 발 접지선을 다시 맞췄다.
정규화 순서
1) 머리 크기를 재서 배율을 구한다
2) 그 배율로 축소한다
3) 발 접지선을 셀 기준 위치로 다시 맞춘다
코드로 옮기면 이렇다.
def normalize(cell, target_head, contact_y, overrides=None):
scale = min(1.0, target_head / head_width(cell)) # 축소만 허용
if overrides:
scale = overrides.get(cell.name, scale) # 등반 행은 실측값
shrunk = cell.resize(
(round(cell.width * scale), round(cell.height * scale)),
Image.LANCZOS,
)
out = Image.new("RGBA", cell.size, (0, 0, 0, 0))
feet = foot_contact_y(shrunk) # 가장 아래 불투명 행
out.paste(shrunk, ((cell.width - shrunk.width) // 2, contact_y - feet))
return out
min(1.0, ...)이 축소 전용 정책을 강제한다. 배율이 1을 넘으면 그냥 1로 잘린다.
붙이는 위치가 contact_y - feet인 게 3번 단계다. 축소된 그림의 발이 항상 같은 높이에 오도록 위에서부터 밀어 넣는다.
크기 정규화와 위치 정규화는 다른 작업이다. 하나만 하면 다른 쪽이 어긋난다.
검수용 이미지를 따로 만들었다
정규화가 잘 됐는지 확인하려면 35장을 나란히 놓고 봐야 한다. 한 장씩 열어 보면 판단이 안 된다.
그래서 전체를 한 장에 이어 붙이고 접지선을 그은 검수용 이미지를 만들어 팩에 넣었다.
character-packs/hermes/qa/all34-normalized-contact.png
이걸 저장소에 커밋해 둔 게 나중에 도움이 됐다. 스프라이트를 손볼 때마다 이 이미지를 다시 만들어 비교하면 무엇이 틀어졌는지 한눈에 보인다.
체크섬 목록에도 들어 있어서, 이 파일이 안 바뀌었으면 정규화 결과가 그대로라는 뜻이 된다.
어디에 쓸 만한가
AI로 만든 에셋을 여러 개 쓰는 곳이면 같은 문제가 난다.
- 캐릭터 동작 세트
- 아이콘 세트 (선 굵기와 여백이 제각각)
- 일러스트 세트 (색조와 채도)
- 목소리 (음량과 속도)
공통 대응은 같다. 자세·내용에 불변인 특징을 기준으로 잡고, 한 방향으로만 맞추고, 예외만 손으로 처리하고, 전체를 한눈에 보는 검수 뷰를 만든다.
아이콘이면 선 굵기가 머리 크기에 해당한다. 목소리면 무음 구간을 뺀 평균 음량이 그렇다.
정리
- 여러 번 요청해 만든 에셋은 크기가 제각각이다. 한 장씩 보면 모른다
- 기준은 자세에 불변인 특징으로 잡는다. 전체 높이나 면적은 자세에 따라 변한다
- 축소만 허용한다. 확대는 화질이 깨진다. 그래서 가장 작은 것에 맞춘다
- “가장 작은 것”이 아니라 “가장 작은 정당한 것” 이다. 잘못 그려져 작은 건 기준이 못 된다
- 대부분은 일괄 배율, 예외 몇 개만 실측한다
- 크기 정규화와 위치(접지선) 정규화는 별개다. 하나만 하면 튄다
- 전체를 한 장에 이어 붙인 검수 이미지를 만들어 커밋해 둔다
한 장씩 보고 “괜찮은데?”라고 판단한 게 이 작업에서 가장 오래 걸린 우회였다.