TECH NOTE · AUTOMATION OPS
크로마키 배경을 지워도 실루엣 가장자리에 색이 남습니다. 원인인 스트레이트 알파 리샘플링과 픽셀을 세는 검사 방법입니다.
배경을 뺐는데 테두리에 색이 남아 있다
캐릭터 스프라이트를 만들 때 배경을 마젠타(형광 분홍)로 깔고 나중에 지우는 방식을 썼다. 캐릭터에 잘 안 쓰이는 색이라 골랐다.
배경은 잘 지워졌다. 그런데 확대해 보니 캐릭터 실루엣 가장자리에 분홍기가 남아 있었다. 머리카락 끝처럼 가늘고 반투명한 부분이 특히 심했다.
화면에서 100%로 보면 거의 안 보인다. 밝은 배경 위에 올리면 티가 난다.
알파를 곱하지 않고 크기를 바꿨다
원인이 예상 밖이었다. 배경 제거가 아니라 크기 조정 단계의 문제였다.
이미지에서 반투명 픽셀을 다룰 때 두 방식이 있다.
- 스트레이트 알파 — RGB와 알파를 따로 저장. RGB는 알파와 무관한 원래 색
- 프리멀티플라이드 알파 — RGB에 미리 알파를 곱해 저장
크기를 줄일 때 이웃 픽셀들의 색을 섞는데, 스트레이트 알파에서는 알파가 0인 픽셀의 RGB도 섞인다.
배경이 완전히 투명해졌어도 그 자리의 RGB는 여전히 마젠타다. 축소하면서 그 마젠타가 캐릭터 가장자리로 번져 들어온다.
커밋 메시지에 이렇게 적어 뒀다.
left over from resizing/pasting the walk strips into the atlas without
premultiplying alpha first — straight-alpha resampling blends edge RGB
with neighbors regardless of alpha, so a magenta cast survives even
where the source alpha was already low.
알파가 낮은 곳에서도 살아남는다는 게 핵심이다. 안 보이는 픽셀의 색이 보이는 픽셀로 옮겨 온다.
눈으로 찾지 말고 세어 본다
이런 종류는 눈으로 판정하면 안 된다. 모니터, 밝기, 배경색에 따라 보이기도 하고 안 보이기도 한다.
그래서 세는 쪽으로 갔다. 판정 기준은 초록 대비 빨강·파랑이 치우친 픽셀이다. 마젠타는 R과 B가 높고 G가 낮다.
거기에 조건을 하나 더 걸었다. 알파가 0인 픽셀은 안 센다. 어차피 안 보이기 때문이다.
from PIL import Image
def scan_magenta(path, bias=40, alpha_floor=8):
"""보이는 픽셀 중 마젠타로 치우친 것을 세고 위치를 함께 돌려준다."""
image = Image.open(path).convert("RGBA")
width, height = image.size
pixels = image.load()
hits = 0
box = None
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a <= alpha_floor:
continue # 안 보이는 픽셀은 문제가 아니다
if min(r, b) - g < bias:
continue # R·B가 G보다 충분히 높지 않다
hits += 1
box = (x, y, x, y) if box is None else (
min(box[0], x), min(box[1], y),
max(box[2], x), max(box[3], y),
)
return hits, box, width * height
min(r, b) - g를 쓴 게 요점이다. R과 B 둘 다 G보다 높아야 마젠타다. 하나만 높으면 빨강이나 파랑이라 캐릭터의 정상 색일 수 있다.
결과를 리포트 파일로 남겼다.
image=.../sprite-sheet-alpha.png
size=2048x2048
visible_magenta_pixels=0
visible_magenta_ratio=0.00000000
visible_magenta_bbox=none
threshold_pixels=64
threshold_ratio=0.00005000
status=pass
값이 몇 개인지 보자.
visible_magenta_pixels— 걸린 픽셀 수visible_magenta_bbox— 걸린 픽셀들이 모여 있는 사각형threshold_pixels/threshold_ratio— 통과 기준status— pass / fail
임계값을 0으로 두지 않았다
threshold_pixels=64가 눈에 띈다. 0이 아니다.
2048×2048은 400만 픽셀이 넘는다. 그중 64개, 비율로 0.005%까지는 통과시킨다.
이유가 있다. 0으로 두면 실무에서 안 쓰이게 된다. 캐릭터가 실제로 분홍 계열 색을 조금 쓰거나, 압축 과정에서 한두 픽셀이 튀면 매번 실패한다. 그러면 사람이 검사를 끄거나 무시한다.
64개는 눈에 안 보이는 양이면서, 진짜 번짐이 있으면 확실히 넘는 수다. 실제 번짐이 있었을 때는 12,821개 픽셀이 걸렸다. 두 자릿수와 다섯 자릿수는 헷갈리지 않는다.
bbox를 같이 남기는 것도 실용적이다. 걸린 픽셀이 한곳에 몰려 있으면 그림 문제고, 흩어져 있으면 압축 잡음일 가능성이 높다.
고칠 때도 알파는 건드리지 않는다
번짐을 실제로 고친 방식이 흥미로웠다. 걸린 픽셀을 지우는 게 아니라 중성색 쪽으로 탈색시켰다.
Desaturated any pixel with an R/B-over-G magenta bias toward neutral
across both walk strips (12,821 pixels changed), preserving alpha so
edges stay soft.
지우면(알파를 0으로) 가장자리가 계단처럼 딱딱해진다. 머리카락 끝처럼 부드러워야 하는 곳이 망가진다.
색만 중성으로 옮기고 알파는 그대로 두면 부드러움을 유지하면서 색조만 사라진다.
고친 뒤 다시 재서 0이 나오는 것까지 확인했다. 그게 이 작업의 완료 조건이었다.
애초에 안 나게 하려면
근본 대응은 크기 조정 전에 알파를 곱하는 것이다.
축소·붙여넣기 전: RGB에 알파를 곱한다 (프리멀티플라이)
축소·붙여넣기 후: 알파로 다시 나눈다 (언프리멀티플라이)
이러면 투명한 곳의 RGB가 0이 되어 섞여도 색이 안 번진다.
다만 도구가 이 옵션을 항상 노출하지는 않는다. 그래서 검사를 남겨 뒀다. 막는 것과 확인하는 것은 둘 다 필요하다.
체크섬 목록에 스프라이트 시트가 들어 있어서, 이미지가 바뀌면 해시가 바뀌고 검사를 다시 돌릴 이유가 생긴다.
어디에 쓸 만한가
에셋 파이프라인에서 눈으로는 잘 안 보이는데 나중에 문제가 되는 것은 전부 같은 방식이 통한다.
| 검사 대상 | 어떻게 세나 |
|---|---|
| 크로마키 잔여 | 특정 색조로 치우친 픽셀 수 |
| 투명 배경에 남은 흰 테두리 | 밝은 반투명 픽셀 수 |
| 잘린 캐릭터 | 셀 경계에 닿은 불투명 픽셀 |
| 빈 프레임 | 불투명 픽셀 총합 |
| 색상 팔레트 이탈 | 허용 색 목록 밖 픽셀 |
공통 형태는 같다. 조건에 걸리는 픽셀을 세고, 임계값과 비교하고, 위치를 함께 남긴다.
정리
- 스트레이트 알파에서 크기를 바꾸면 투명한 곳의 RGB도 섞인다. 배경색이 실루엣으로 번진다
- 크기 조정 전에 알파를 곱하면(프리멀티플라이) 안 생긴다
- 눈으로 판정하면 모니터와 배경에 따라 결과가 달라진다. 픽셀을 센다
- 알파가 0인 픽셀은 세지 않는다. 안 보이는 것은 문제가 아니다
- 임계값을 0으로 두지 않는다. 매번 실패하는 검사는 꺼진다. 실제 번짐은 자릿수가 다르다
- 걸린 픽셀의 위치(bbox)를 함께 남긴다. 몰려 있으면 그림, 흩어져 있으면 잡음
- 고칠 때는 지우지 말고 색만 중성으로. 알파를 건드리면 가장자리가 딱딱해진다
- 막는 것(프리멀티플라이)과 확인하는 것(검사)은 둘 다 필요하다
100%로 보고 “괜찮네”라고 넘어간 게 나중에 밝은 배경 위에서 드러났다.