사진을 도트(픽셀아트)로 바꾸는 도구 만들기
블로그에 이미지 도트 변환 도구를 붙였다. 사진을 넣으면 픽셀아트로 바꿔주는 도구다.
만드는 원리 자체는 세 줄이다. 그런데 그 세 줄을 그냥 쓰면 도트가 안 나온다. 걸린 지점들을 순서대로 적는다.
원리는 세 단계다
사진 → 아주 작게 줄인다 → 색 수를 줄인다 → 다시 크게 확대한다
작게 줄이는 단계에서 여러 픽셀의 색이 하나로 뭉쳐진다. 머리카락이나 옷의 미세한 무늬처럼 도트로 표현할 수 없는 것들이 여기서 사라진다.
색을 줄이는 단계는 손으로 찍은 그림처럼 보이게 만드는 부분이다. 사진에는 수만 가지 색이 들어 있는데, 도트 그림은 보통 몇 가지 색으로 그려지기 때문이다.
코드로는 이렇다.
// 1. 64픽셀 폭으로 줄인다
const small = document.createElement('canvas');
small.width = 64;
small.height = Math.round((64 * bitmap.height) / bitmap.width);
const ctx = small.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, small.width, small.height);
// 2. 색을 줄인다 (뒤에서 다시 다룬다)
// 3. 다시 확대한다
const big = document.createElement('canvas');
big.width = small.width * 16;
big.height = small.height * 16;
big.getContext('2d')!.drawImage(small, 0, 0, big.width, big.height);
이대로 돌리면 도트가 아니라 그냥 흐릿한 사진이 나온다.
부드럽게 늘리기를 꺼야 한다
캔버스는 기본적으로 이미지를 부드럽게 그린다. 확대할 때 픽셀 사이를 색으로 메워주는 건데, 사진에는 좋지만 도트에는 정반대다. 애써 만든 각진 경계가 전부 번져버린다.
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, big.width, big.height);
이 한 줄이 도구 전체에서 가장 중요하다. 도트가 도트로 보이는 이유가 이것뿐이다.
헷갈렸던 건 같은 설정을 단계마다 반대로 둬야 한다는 점이었다. 줄일 때는 켜야 색이 고르게 섞이고, 늘릴 때는 꺼야 경계가 남는다. 하나의 함수에서 두 번 그린다면 중간에 값을 바꿔줘야 한다.
ctx.imageSmoothingEnabled = true; // 줄일 때
ctx.imageSmoothingQuality = 'high';
// ...
ctx.imageSmoothingEnabled = false; // 늘릴 때
정수 배율이 아니면 도트가 들쭉날쭉해진다
원본 크기로 되돌리려고 배율을 그대로 계산했다가 격자가 어긋났다. 가로 1000픽셀 사진을 64도트로 줄이면 배율이 15.625배가 된다. 정수가 아니다.
64도트 × 15.625 = 1000px
어떤 도트는 15칸, 어떤 도트는 16칸을 차지한다
(64도트 평균 15.625칸)
도트마다 차지하는 칸 수가 달라진다. 눈으로 보면 세로줄이 미묘하게 굵었다 얇았다 하고, 확대해서 보면 확실히 티가 난다.
그래서 배율을 반올림해서 정수로만 쓰고, 저장 크기는 원본과 정확히 같지 않고 가장 가까운 배수로 두었다. 위 예라면 16배인 1024픽셀이다. 24픽셀 차이는 쓰는 데 지장이 없다.
색을 줄이는 방법을 바꿨다
처음에는 채널을 균등하게 쪼개려고 했다. R·G·B를 각각 4단계씩 나누면 64색이 되는 식이다. 계산이 간단해서 이걸로 시작했는데, 색 수를 적게 내릴수록 결과가 탁해진다.
이유는 원본에 거의 없는 색이 팔레트 자리를 차지하기 때문이다. 파란 하늘 사진이라면 팔레트 대부분이 파랑이어야 하는데, 균등하게 쪼개면 쓰이지도 않을 자주색과 노랑에 자리를 내주게 된다.
그래서 메디안 컷으로 바꿨다. 색을 상자에 담고, 가장 넓게 퍼진 축에서 반으로 자르기를 반복하는 방식이다.
while (boxes.length < count) {
// 색 범위가 가장 넓은 상자와 그 축을 찾는다
const box = boxes[target];
box.sort((a, b) => a[axis] - b[axis]);
const mid = box.length >> 1;
boxes.splice(target, 1, box.slice(0, mid), box.slice(mid));
}
// 상자마다 평균색을 내면 그게 팔레트
픽셀 수를 기준으로 자르기 때문에 많이 쓰인 색일수록 상자를 더 많이 가져간다. 하늘이 넓게 깔린 사진이면 하늘색이 여러 단계로 나뉘고, 구석의 빨간 표지판은 한 칸만 받는다.
단색 이미지에 8색을 요청하면 8색이 나오지 않는다. 더 자를 상자가 없어서 반복이 그냥 끝나고 1색만 나온다. 요청한 수보다 적게 나오는 게 정상 동작이라, 팔레트 개수를 믿고 배열에 접근하는 코드를 쓰면 안 된다.
디더링은 규칙적으로 흩뿌리는 것
색을 줄이면 그러데이션이 있던 자리에 띠가 생긴다. 하늘처럼 서서히 변하는 면이 계단으로 끊기는 현상이다.
디더링은 그 경계를 흩뜨리는 방법이다. 픽셀 위치마다 정해진 크기만큼 밝기를 밀어둔 다음 가까운 색을 고르면, 같은 색이 넓게 뭉치는 대신 규칙적인 무늬로 섞인다. 베이어 4x4 행렬을 쓰면 코드가 짧다.
const BAYER = [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5],
];
const shift = (BAYER[y & 3][x & 3] / 16 - 0.5) * spread;
const color = nearest(palette, r + shift, g + shift, b + shift);
spread를 얼마로 둘지가 애매했다. 팔레트의 색 간격만큼 밀어야 하는데, 흑백이나 세피아처럼 한 줄로 늘어선 팔레트는 255 / 색 수로 나온다. 반면 원본색 팔레트는 색이 세 채널에 흩어져 있어서 같은 식이 안 맞는다.
세제곱근을 취해서 255 / ∛색 수로 두고 0.8을 곱했다. 이 값이 절대적인 기준은 아니고 화면을 보면서 맞춘 값이다. 더 나은 계산법이 있을 것 같은데 아직 찾지 못했다.
동작 확인은 극단적인 경우로 했다. 온통 50% 회색인 이미지에 검정·흰색 2색 팔레트를 씌우면 체커보드가 나와야 한다. 회색은 검정과 흰색의 딱 중간이라 밀어낸 방향에 따라 갈리기 때문이다.
0 255 0 255
255 0 255 0
0 255 0 255
255 0 255 0
의도한 대로 나왔다.
저장은 PNG로 고정했다
내보내기 형식을 고르게 할까 하다가 PNG 하나로 두었다.
JPG는 사진을 위한 형식이다. 사람 눈에 잘 안 띄는 미세한 차이를 버리면서 용량을 줄이는데, 도트 그림은 그 미세한 차이가 아니라 각진 경계 자체가 내용이다.
그래서 도트 그림을 JPG로 저장하면 경계마다 얼룩이 끼고, 4색으로 줄여놓은 그림에 없던 색이 다시 생긴다. 색 수를 줄이려고 한 작업을 저장 단계에서 되돌리는 셈이다.
PNG는 픽셀을 그대로 보존하고, 색 수가 적으면 대개 용량도 함께 작아진다. 도트 그림에는 이쪽이 맞아서 고를 여지를 두지 않았다.
반투명은 정리했다
투명한 부분이 있는 이미지를 줄이면 가장자리에 반투명 픽셀이 생긴다. 원래는 부드럽게 보이라고 있는 것인데, 도트에서는 윤곽을 흐리기만 한다.
d[i + 3] = d[i + 3] < 128 ? 0 : 255;
절반을 기준으로 투명하거나 불투명하거나 둘 중 하나로 정했다. 도트 그림에 반쯤 비치는 픽셀이 있으면 어차피 어색하다.
정리
막힌 곳이 전부 “당연히 이렇게 되겠지”라고 넘어간 자리였다.
- 확대하면 도트가 보이겠지 → 부드럽게 늘리기를 꺼야 했다
- 원본 크기로 되돌리면 되겠지 → 정수 배율이 아니면 격자가 어긋난다
- 색은 균등하게 나누면 되겠지 → 원본에 없는 색이 팔레트를 차지한다
전부 브라우저 안에서 도는 도구라 이미지가 서버로 가지 않는다. 도구는 /tools/pixel-art에 있다.