김솔비 블로그
기술 블로그

이미지에서 '대표 색' 뽑기: 평균색이 늘 분홍이 되던 이유

8분 읽기

TL;DR

  • 테마 캡처는 대부분 흰 바탕이다. 평균을 내면 색이 사라지고, 무채색일 때 쓰던 기본값 분홍이 늘 나왔다.
  • 평균 대신 채도로 가중한 색상(hue) 히스토그램에서 가장 많은 색 무리를 고른다. 이웃 칸을 묶고 원형 평균으로 색상을 정한다.
  • 시나모롤처럼 아주 옅은 테마는 기준을 낮춰 한 번 더 보고, 이미 디자인된 그림은 빼고, 글 하나에는 색 하나만 쓴다.

들어가며

오토포스트는 카톡 테마 상품을 등록하면 인스타 첫 장, 인스타 마지막 장, 네이버 블로그 첫 사진을 자동으로 그린다. 제목 글자, 배지, 버튼, 배경 그라데이션은 모두 상품의 "대표 색" 하나를 따라간다.

그런데 쓰다 보니 이런 말이 나왔다. "인스타 첫 장 텍스트 컬러가 왜 똑같아? 이미지에 따라서 달라야 하는 거 아니야?" 시나모롤(하늘색), 한교동(청록), 헬로키티(분홍) 테마가 모두 같은 분홍 제목을 달고 있었다.

원인: 평균색은 흰 바탕에서 색을 잃는다

처음 코드는 단순했다. 캐릭터 이미지가 있으면 그 평균색을, 없으면 첫 화면 캡처의 평균색을 구해서 밝고 선명하게 바꿨다.

java
/** 제목 색: 기준 색을 밝고 선명하게. 무채색이면 분홍 */ static Color pop(Color base) { float[] hsb = Color.RGBtoHSB(base.getRed(), base.getGreen(), base.getBlue(), null); if (hsb[1] < 0.08f) { return new Color(0xf7a8bd); // 색이 거의 없으면 기본 분홍 } return Color.getHSBColor(hsb[0], 0.45f, 1f); }

카톡 테마 캡처는 화면 대부분이 흰색이나 아주 옅은 배경이다. 테마 색은 말풍선, 아이콘, 캐릭터처럼 작은 면적에만 있다. 평균을 내면 이 작은 색들이 흰 바탕에 묻힌다.

시나모롤 블루 배경 캡처 4장으로 직접 계산해 봤다. 평균색은 rgb(232, 239, 245), 채도는 0.05였다. 기준인 0.08보다 낮으니 무조건 분홍이 나온다. 테마가 무엇이든 캡처가 밝기만 하면 같은 결과가 나오는 구조였다.

방법: 채도로 가중한 색상 히스토그램

알고 싶은 건 "화면 전체의 평균"이 아니라 "이 테마에서 가장 많이 쓰인 색"이다. 그래서 색상환을 24칸(15도씩)으로 나누고, 색이 있는 점만 세는 히스토그램으로 바꿨다.

java
for (BufferedImage im : images) { double w = isCharacter(im) ? 3 : 1; // 캐릭터는 3배로 친다 int step = Math.max(1, Math.max(im.getWidth(), im.getHeight()) / 96); for (int y = 0; y < im.getHeight(); y += step) { for (int x = 0; x < im.getWidth(); x += step) { int p = im.getRGB(x, y); if ((p >>> 24) < 128) continue; // 투명한 곳(캐릭터 배경)은 건너뛴다 Color.RGBtoHSB((p >> 16) & 0xff, (p >> 8) & 0xff, p & 0xff, hsb); if (hsb[1] < minSat || hsb[2] < minBright) continue; // 흰색·회색·검은 글자는 뺀다 int bin = Math.min(23, (int) (hsb[0] * 24)); double v = w * hsb[1]; // 진한 색일수록 크게 센다 weight[bin] += v; sx[bin] += v * Math.cos(hsb[0] * 2 * Math.PI); sy[bin] += v * Math.sin(hsb[0] * 2 * Math.PI); } } }

몇 가지를 정했다.

  • 흰색·회색·검은 글자는 뺀다. 채도 0.18 미만, 밝기 0.35 미만인 점은 세지 않는다. 바탕과 글자가 결과를 흐리지 않게 하려는 것이다.
  • 채도로 가중한다. 옅은 분홍 한 점보다 진한 분홍 한 점을 더 크게 센다.
  • 캐릭터는 3배로 친다. 테마의 얼굴은 캐릭터라서, 캐릭터 색이 캡처의 작은 아이콘보다 우선한다.
  • 한 변을 96칸 정도로만 본다. 모든 픽셀을 볼 필요는 없다. 라즈베리파이에서 사진을 다시 그릴 때마다 돌기 때문에 가볍게 했다.

가까운 색은 한 무리로

칸을 24개로 나누면 같은 분홍이 "330도 칸"과 "345도 칸"으로 갈라질 수 있다. 그래서 가장 큰 칸이 아니라 양옆 칸까지 더한 점수가 가장 큰 칸을 고른다.

java
double score = weight[i] + 0.75 * (weight[i - 1] + weight[i + 1]) // 15도 옆 + 0.5 * (weight[i - 2] + weight[i + 2]); // 30도 옆 (인덱스는 24로 감는다)

색상은 원형 평균으로

고른 칸과 양옆 두 칸(±30도)의 색상을 평균해서 최종 색상을 정한다. 이때 그냥 숫자 평균을 내면 안 된다. 색상은 원이라서 350도와 10도의 평균은 180도(청록)가 아니라 0도(빨강)여야 한다. 그래서 각 색상을 단위원 위의 점(cos, sin)으로 바꿔 더한 뒤 atan2로 각도를 되찾는다.

java
double hue = Math.atan2(y, x) / (2 * Math.PI); // -0.5 ~ 0.5 float h = (float) (hue < 0 ? hue + 1 : hue); float s = (float) Math.max(0.42, Math.min(0.68, avgSat)); // 너무 탁하거나 너무 쨍하지 않게 return Color.getHSBColor(h, s, 1f);

채도는 0.42~0.68 사이로 묶고 밝기는 1로 둔다. 제목은 두꺼운 테두리를 두르기 때문에 밝은 파스텔이어야 테마 분위기와 맞는다.

글자 테두리도 검정 대신 같은 색 계열의 아주 어두운 색으로 바꿨다. 하늘색 제목에는 남색 테두리, 분홍 제목에는 짙은 자주 테두리가 붙어서 그림과 더 잘 어울린다.

java
static Color inkOf(Color accent) { float[] hsb = Color.RGBtoHSB(accent.getRed(), accent.getGreen(), accent.getBlue(), null); return Color.getHSBColor(hsb[0], Math.min(0.6f, hsb[1] + 0.1f), 0.24f); }

예외 1: 그림마다 색이 달랐다

배포하고 다시 그려 보니 같은 글인데 인스타 마지막 장은 분홍, 블로그 첫 사진은 주황으로 나왔다. 그림마다 넣는 사진이 달랐기 때문이다. 마지막 장은 첫 캡처 한 장으로, 블로그 첫 사진은 폰 모양 캡처 위주로 색을 정하고 있었다.

첫 수정 직후. 줄마다 마지막 장 전·후, 블로그 첫 사진 전·후 순서다. 위(한교동)는 분홍에서 청록으로 잘 바뀌었지만, 아래(헬로키티)는 마지막 장은 분홍인데 블로그 첫 사진만 주황이 됐다

대표 색은 그림이 아니라 글(상품)의 속성이다. 그래서 다시 그릴 때 글마다 한 번만 정하고, 첫 장·블로그 첫 사진·마지막 장에 같은 값을 넘기도록 바꿨다. 상품 화면에서 대표 색을 직접 고르면 그 색이 가장 먼저다.

java
Color productAccent(Product product) { Color chosen = ThemeCover.parseColor(product.getAccentColor()); if (chosen != null) return chosen; // 직접 고른 색이 먼저 List<BufferedImage> screens = themeShots(product).stream().limit(6).map(this::readImage).toList(); Color c = ThemeCover.dominantHue(objectImages(product.getId()), screens); if (c == null && !screens.isEmpty()) { c = ThemeCover.accentOf(null, objects, ThemeCover.average(screens.getFirst())); // 그래도 한 번만 정한다 } return c; }

예외 2: 이미 디자인된 그림이 색을 흐렸다

헬로키티 하트 배경 글은 분홍 테마인데 주황이 나왔다. 사진별로 색상 칸을 세어 보니 원인이 보였다.

사진가장 많은 색상 칸
네이버 글 캡처 1~3330도(분홍), 15도, 30도가 비슷
예전 인스타 첫 장(가져온 사진)15도(주황빛) 113.5, 330도 53.0
합계15도 153.0, 330도 99.3

예전 인스타 게시물에서 가져온 사진은 테마 캡처가 아니라 이미 디자인한 그림이었다. 제목 글자와 장식에 주황 계열이 들어 있었고, 그 한 장이 전체 점수를 끌고 갔다. 그래서 대표 색을 정할 때는 예전 인스타에서 가져온 사진을 빼고 테마 캡처만 본다. 가까운 색을 묶는 범위도 ±1칸에서 ±2칸으로 넓혔다. 그 결과 #ff8e81(연어빛 분홍)로 바뀌었다.

예외 3: 아주 옅은 파스텔 테마

시나모롤은 반대 문제였다. 테마 자체가 아주 옅은 하늘색이라 "선명한 색"이 거의 없었다. 위 기준(채도 0.18 이상)으로 세면 색 있는 점의 비율이 기준(0.4%)에 못 미쳐 대표 색을 못 정했고, 다시 기본값 분홍으로 떨어졌다.

시나모롤 블루 배경 캡처의 색상 히스토그램. 1단계에서는 색 있는 점이 너무 적고, 2단계에서 하늘색(195도 부근)이 뚜렷하게 잡힌다

그래서 두 단계로 본다. 1단계에서 못 정하면 기준을 채도 0.05, 밝기 0.6으로 낮춰 한 번 더 센다. 흰색(채도 0에 가까움)과 어두운 글자는 여전히 빠지고, 옅은 하늘색 바탕은 잡힌다.

java
static Color dominantHue(List<BufferedImage> objs, List<BufferedImage> screens) { Color vivid = dominantHue(objs, screens, 0.18f, 0.35f); return vivid != null ? vivid : dominantHue(objs, screens, 0.05f, 0.6f); }

처음부터 기준을 낮추지 않은 이유가 있다. 선명한 색이 있는 테마에서 옅은 바탕색까지 세면, 넓은 바탕이 작은 포인트 색을 이겨 버린다. 선명한 색이 있으면 그것을 먼저 믿는다.

시나모롤 세 버전(그라데이션·블루·화이트 배경)의 블로그 첫 사진과 마지막 장이 모두 하늘색으로 맞춰졌다

결과

테마전후
반짝이는 시나모롤 (3개 버전)분홍, 버전에 따라 파랑이 섞임하늘색 #94cfff~#94d3ff
조명을 든 한교동 (블루 배경)분홍청록 #94f0ff
픽셀 헬로키티 (하트 배경)분홍 → 고치는 중 한 번 주황연어빛 분홍 #ff8e81
Blush Heart분홍분홍

여러 글의 마지막 장과 블로그 첫 사진. 글마다 색이 다르고, 같은 글 안에서는 같다

테스트도 두 개 넣었다. 흰 바탕에 파란 말풍선 몇 개만 있는 그림은 파랑 계열이 나와야 하고, 거의 흰색에 아주 옅은 하늘색만 있는 그림도 분홍이 아니라 파랑 계열이 나와야 한다.

왜 k-means가 아니었나

이미지 대표 색이라면 흔히 k-means로 색을 몇 개 묶는 방법을 떠올린다. 이번에는 쓰지 않았다.

  • 필요한 건 팔레트가 아니라 색상 하나다. 결과 색의 채도와 밝기는 어차피 디자인 규칙으로 다시 정한다. 묶음의 중심 RGB가 아니라 "어느 색상 쪽이냐"만 알면 된다.
  • 흰 바탕을 따로 빼야 한다. k-means로 묶어도 가장 큰 묶음은 흰 바탕이다. 결국 색 없는 묶음을 걸러 내는 규칙이 필요한데, 그 규칙을 먼저 적용하면 남은 문제는 히스토그램으로 충분했다.
  • 같은 입력에 같은 결과. 히스토그램은 초기값이나 반복 횟수가 없어서 다시 그릴 때마다 색이 흔들리지 않는다.
  • 가볍다. 이미지 한 장당 한 변 96칸 정도만 한 번 훑는다. 라즈베리파이에서 사진을 다시 그릴 때마다 함께 돈다.

회고

  • 평균은 "얼마나 많은가"를 잃는다. 넓은 흰 바탕과 작은 포인트 색이 섞이면 평균은 둘 다 아닌 색이 된다. 대표값을 고를 때는 먼저 무엇을 대표하고 싶은지 정해야 했다.
  • 규칙은 실제 데이터에 돌려 봐야 안다. 헬로키티가 주황으로 나온 건 사진별 점수를 직접 세어 보고서야 알았다. 숫자를 표로 놓고 보니 이미 디자인된 그림 한 장이 원인이라는 게 바로 보였다.
  • 값의 주인을 정하자. 대표 색은 그림마다가 아니라 글마다 하나다. 이걸 정하고 나니 "같은 글인데 색이 다르다"는 종류의 문제가 사라졌다.