Posts

검정 배경과 중복 이미지, 브라우저에서 픽셀로 걸러내기

2026-06-02

기존 커머스팀에서 병목현상이 발생하는 업무 구간이 있었어요. 셀러가 상품을 등록하면 어드민에서 해당 상품 이미지, 설명, 구성요소등이 정책에 맞게 등록됐는지 확인하는 과정이 바로 병목 지점이였어요. 하나의 상품만 확인하면 괜찮지만 하루 평균 최소 10개 이상의 요청이 발생하여 커머스팀에서도 노고가 드는 작업이였죠.

이번 글에서는 해당 병목현상을 자동화로 해결할 방법을 고민하다 그중 이미지와 관련된 두 가지 검사만 다뤄요.

  1. 썸네일 배경이 검정 계통인지 확인
  2. 썸네일과 대표사진이 동일 이미지인지 확인

처음에는 AI 모델을 사용해서 자동화를 진행하려고 했어요. 그런데 조금 더 설계를 고민하다 보니 이 정도 작업은 AI 없이 상품 등록 전에 셀러가 스스로 반려 가능성을 확인할 수 있도록 하는 게 좀 더 좋지 않을까란 생각이 들었습니다.

1. 썸네일 배경이 검정 계통인지 확인하기

처음에는 이미지 테두리의 평균 밝기가 일정 값보다 낮으면 경고하는 방향으로 작업을 진행했어요. 그러나 실제로는 육안상 괜찮은 회색이나 컬러 배경도 걸리는 경우가 있었어요. 그래서 기준을 바꿨죠.

썸네일 배경이 검정 계통일 때만 경고한다.

이를 위해 전체 이미지가 아니라 테두리 영역만 샘플링해요.

상품 썸네일은 보통 상품이 중앙에 있고, 배경은 바깥쪽에 많이 남아요. 전체 이미지를 평균 내면 검정 상품 자체 때문에 배경이 어둡다고 오판할 수 있거든요.

const BORDER_TONE_SAMPLE_MAX_SIZE = 96;
const BORDER_SAMPLE_RATIO = 0.18;

export async function getImageBorderTone(
  image: UploadedFileInfos,
): Promise<ImageBorderTone | null> {
  const imageElement = await loadImageElement(image);

  if (!imageElement) {
    return null;
  }

  try {
    const { canvas, context } = drawImageOnCanvas(
      imageElement,
      BORDER_TONE_SAMPLE_MAX_SIZE,
    );

    const { width, height } = canvas;

    const borderSize = Math.max(
      1,
      Math.floor(Math.min(width, height) * BORDER_SAMPLE_RATIO),
    );

    const imageData = context.getImageData(0, 0, width, height).data;

    let brightnessSum = 0;
    let pixelCount = 0;
    let darkPixelCount = 0;
    let blackPixelCount = 0;

    // ...
  } finally {
    revokeObjectUrl(imageElement);
  }
}

여기서 BORDER_TONE_SAMPLE_MAX_SIZE(96)은 샘플링용 캔버스의 최대 크기예요. 원본 이미지 전체를 그대로 읽기보다는 축소된 이미지로 배경 여부를 판단하는 게 효율적이라 판단했죠. 96px 정도면 테두리 픽셀이 수천 개 확보고 동시에 브라우저에서 픽셀을 순회하는 비용도 낮게 유지할 수 있죠.

BORDER_SAMPLE_RATIO(0.18)은 각 방향 테두리 18%를 배경 후보로 보겠다는 뜻이에요.

너무 적게 잡으면 그림자나 프레임 같은 작은 요소에 민감해지고, 너무 많이 잡으면 중앙 상품의 색상이 섞여요. 특히 검정 상품이 중앙에 크게 있는 경우, 배경이 밝아도 전체가 어둡게 판단될 수 있거든요. 그래서 테두리 영역만 적당히 넓게 잡는 값으로 18%를 사용했어요.

2. 픽셀 밝기 계산하기

각 픽셀의 밝기는 RGB를 단순 평균하지 않고, 사람이 느끼는 밝기에 가까운 가중치를 사용했어요.

function getPixelBrightness(red: number, green: number, blue: number) {
  return (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
}

초록색은 사람이 더 밝게 느끼고, 파란색은 상대적으로 어둡게 느껴요. 그래서 일반적인 luminance 계산식에 가까운 가중치를 사용했죠.

luminance 계산식이 뭔가요?

luminance(휘도)는 화면의 빛이 사람 눈에 실제로 얼마나 밝게 보이는지를 나타내는 값이에요. 사람의 눈은 색마다 밝기를 다르게 느끼기 때문에, RGB를 똑같은 비율로 더하면 실제 체감 밝기와 어긋나요.

그래서 사람 눈의 민감도에 맞춰 초록(0.587)에 가장 큰 가중치를, 파랑(0.114)에 가장 작은 가중치를 주고, 빨강(0.299)은 그 중간으로 둬요. 이 세 가중치를 곱해 더한 값이 luminance이고, 위 코드는 거기에 255로 나눠 0~1 범위로 정규화한 형태예요.

결과는 0부터 1 사이의 값이에요.

  • 0: 검정
  • 1: 흰색

3. 검정 배경 판정에 필요한 세 가지 값

const DARK_PIXEL_BRIGHTNESS_THRESHOLD = 0.36;
const BLACK_PIXEL_BRIGHTNESS_THRESHOLD = 0.24;
const VISIBLE_ALPHA_THRESHOLD = 20;
if (alpha < VISIBLE_ALPHA_THRESHOLD) continue;

const brightness = getPixelBrightness(
  imageData[index], //R
  imageData[index + 1], //G
  imageData[index + 2], //B
);

brightnessSum += brightness;

if (brightness <= DARK_PIXEL_BRIGHTNESS_THRESHOLD) {
  darkPixelCount++;
}

if (brightness <= BLACK_PIXEL_BRIGHTNESS_THRESHOLD) {
  blackPixelCount++;
}

pixelCount++;

DARK_PIXEL_BRIGHTNESS_THRESHOLD(0.36)은 어두운 픽셀 기준이에요. 짙은 회색이나 검정 계열을 포함해요.

BLACK_PIXEL_BRIGHTNESS_THRESHOLD(0.24)는 더 엄격한 기준이에요. 검정에 가까운 픽셀만 세죠.

VISIBLE_ALPHA_THRESHOLD(20)은 투명 픽셀을 제외하기 위한 alpha 기준이에요. 투명한 PNG 영역이 검정처럼 계산되는 일을 막아줘요.

최종적으로 이런 형태의 값을 반환해요.

return {
  averageBrightness: brightnessSum / pixelCount,
  darkPixelRatio: darkPixelCount / pixelCount,
  blackPixelRatio: blackPixelCount / pixelCount,
};
  • averageBrightness: 테두리 전체 평균 밝기
  • darkPixelRatio: 어두운 픽셀 비율
  • blackPixelRatio: 검정에 가까운 픽셀 비율

4. "검정 계통 배경"으로 볼지 결정하기

실제 판정은 별도 함수로 분리했어요.

const BLACK_BACKGROUND_AVERAGE_BRIGHTNESS_THRESHOLD = 0.32;
const DARK_PIXEL_RATIO_THRESHOLD = 0.55;
const BLACK_PIXEL_RATIO_THRESHOLD = 0.65;

export function hasBlackToneBackground({
  averageBrightness,
  darkPixelRatio,
  blackPixelRatio,
}: ImageBorderTone) {
  return (
    blackPixelRatio >= BLACK_PIXEL_RATIO_THRESHOLD ||
    (averageBrightness <= BLACK_BACKGROUND_AVERAGE_BRIGHTNESS_THRESHOLD &&
      darkPixelRatio >= DARK_PIXEL_RATIO_THRESHOLD)
  );
}

조건은 두 갈래예요.

첫 번째 조건이에요.

blackPixelRatio >= BLACK_PIXEL_RATIO_THRESHOLD;

테두리 픽셀 중 검정에 가까운 픽셀이 65% 이상이면 검정 배경으로 봐요. 평균 밝기가 약간 올라가더라도 검정 픽셀이 대부분이면 검정 계통 배경이라고 판단하기 위해서예요.

두 번째 조건이에요.

averageBrightness <= BLACK_BACKGROUND_AVERAGE_BRIGHTNESS_THRESHOLD &&
  darkPixelRatio >= DARK_PIXEL_RATIO_THRESHOLD;

테두리 평균 밝기가 충분히 낮고, 어두운 픽셀도 과반 이상이면 검정 계통으로 봐요. 테두리 일부에 그림자나 어두운 상품 일부가 걸릴 수 있기 때문에 평균 밝기 하나만 보지 않았죠. 평균 밝기와 어두운 픽셀 비율을 함께 봐야 오탐을 줄일 수 있다 판단했어요.

5. 썸네일과 대표사진 중복 확인하기

두 번째 이미지 검사는 썸네일과 대표사진이 같은 이미지인지 확인하는 거예요.

먼저 가장 확실한 비교부터 해요.

function isSameImageBySource(
  imageA: UploadedFileInfos,
  imageB: UploadedFileInfos,
) {
  if (imageA.url && imageB.url && imageA.url === imageB.url) {
    return true;
  }

  if (!imageA.file || !imageB.file) {
    return false;
  }

  return (
    imageA.file.name === imageB.file.name &&
    imageA.file.size === imageB.file.size &&
    imageA.file.lastModified === imageB.file.lastModified
  );
}

URL이 같으면 같은 이미지로 봐요.

로컬 파일이라면 파일명, 파일 크기, 마지막 수정 시각이 모두 같을 때 같은 파일로 판단해요.

하지만 여기에는 한계가 있어요.

같은 이미지를 다시 저장하거나 리사이즈하면 파일명이나 메타데이터가 달라질 수 있거든요. 그래서 한 단계 더 들어가서 이미지 자체의 패턴을 비교해요.

6. Average Hash로 유사 이미지 비교하기

이미지를 8x8로 줄인 뒤, 각 픽셀이 전체 평균보다 밝은지 여부를 문자열로 만들어요.

const AVERAGE_HASH_SIZE = 8;

export async function getImageAverageHash(image: UploadedFileInfos) {
  const imageElement = await loadImageElement(image);

  if (!imageElement) {
    return null;
  }

  try {
    const { context } = drawImageOnFixedCanvas(imageElement, AVERAGE_HASH_SIZE);

    const imageData = context.getImageData(
      0,
      0,
      AVERAGE_HASH_SIZE,
      AVERAGE_HASH_SIZE,
    ).data;

    const brightnessList = [];

    for (let index = 0; index < imageData.length; index += 4) {
      brightnessList.push(
        getPixelBrightness(
          imageData[index],
          imageData[index + 1],
          imageData[index + 2],
        ),
      );
    }

    const averageBrightness =
      brightnessList.reduce((sum, brightness) => sum + brightness, 0) /
      brightnessList.length;

    return brightnessList
      .map((brightness) => (brightness >= averageBrightness ? "1" : "0"))
      .join("");
  } finally {
    revokeObjectUrl(imageElement);
  }
}

8x8을 쓰면 64개의 밝기 패턴이 만들어져요.

이 방식은 이미지의 세부 픽셀을 그대로 비교하지 않아요. 대신 전체적인 밝기 구조를 비교하죠. 그래서 리사이즈나 압축 정도의 변화에는 비교적 덜 민감하고, 같은 이미지인지 빠르게 추정할 수 있어요.

예를 들어 결과는 이런 형태가 돼요.

"11110000111100001111000000001111...";

7. Hamming Distance로 얼마나 비슷한지 보기

두 hash 문자열이 얼마나 다른지는 Hamming distance로 계산해요.

export function getHammingDistance(valueA: string, valueB: string) {
  return valueA.split("").reduce((distance, value, index) => {
    return value === valueB[index] ? distance : distance + 1;
  }, 0);
}

두 문자열의 같은 위치를 비교해서 다른 문자의 개수를 세요.

if (imageHash && getHammingDistance(thumbnailHash, imageHash) <= 4) {
  return createWarningResult(
    3,
    PRE_APPROVAL_REJECTION_REASONS.THUMBNAIL_AND_REQUIRED_IMAGE_DUPLICATED,
    "썸네일과 거의 동일한 대표사진이 있습니다.",
  );
}

현재 기준은 4 이하예요.

64비트 중 4비트 이하만 다르면 거의 같은 이미지로 봐요. 완전히 동일하지 않아도, 리사이즈나 재압축 정도로 생기는 작은 차이는 중복으로 잡기 위한 기준이에요.

한 가지 짚어둘 점은, 이 방식이 getPixelBrightness로 RGB를 밝기 한 값으로 뭉개기 때문에 색상 정보는 비교에 반영되지 않는다는 거예요. 구도와 밝기 구조가 같고 색만 다른 이미지는 같은 이미지로 판단돼요.

이 한계는 알고 있었지만 배경색만 다른 건 사실상 같은 사진이라고 봤기 때문에, 색상까지 구분하지 않는 average hash를 사용했어요. 색상 변형을 별도 이미지로 구분해야 하는 상황이라면 채널별 hash나 색 분포 비교를 더해야 해요.

정리

이번 이미지 사전체크는 복잡한 AI 모델 없이도 브라우저에서 바로 실행할 수 있는 방식으로 만들었어요.

핵심은 두 가지예요.

첫째, 썸네일 배경 검사는 전체 이미지가 아니라 테두리만 봐요. 그래야 중앙 상품 색상 때문에 배경을 오판하는 일을 줄일 수 있거든요.

둘째, 이미지 중복 검사는 URL과 File 비교를 먼저 하고, 그다음 average hash로 유사 이미지를 비교해요. 단순 메타데이터 비교로 놓칠 수 있는 "같은 이미지 재저장" 케이스를 보완하기 위해서죠.

물론 처음 의도했던 완전 자동화와는 거리가 있어요. 그래도 셀러가 등록 전에 스스로 반려 가능성을 확인할 수 있는 장치를 만든 덕분에, 커머스팀의 병목을 조금이나마 덜어낼 수 있었다는 점은 만족스러웠어요.

가장 이상적인 방향은 상품 업로드 후 이 로직을 람다 같은 서버 환경에서 돌려 검수를 완전히 자동화하는 거였지만 비용과 회사 상황 때문에 여기서 멈춰야 했던 건 아쉬움이 큰 작업이었어요.