검정 배경과 중복 이미지, 브라우저에서 픽셀로 걸러내기
예전에 있던 회사의 커머스팀에는 매일 병목 구간이 하나 있었습니다. 셀러가 상품을 등록하면 어드민에서 그 상품의 이미지, 설명, 구성요소가 정책에 맞게 등록됐는지 사람이 직접 확인해야 했기 때문입니다. 하나만 보면 별일 아니지만, 하루 평균 최소 열 건 이상의 요청이 밀려들었습니다. 커머스팀 입장에서는 꽤 큰 노고가 드는 작업이었죠.
이번 글에서는 그 병목을 자동화로 풀어보려 고민한 것 중, 이미지와 관련된 두 가지 검사만 다룹니다.
- 썸네일 배경이 검정 계통인지 확인
- 썸네일과 대표사진이 동일 이미지인지 확인
처음에는 당연히 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를 그냥 평균 내면 안 됐다
각 픽셀의 밝기는 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. 8x8로 뭉개면 같은 사진인지 보인다
이미지를 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. 얼마나 달라야 다른 이미지인가
두 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로 유사 이미지를 비교합니다. 단순 메타데이터 비교로 놓칠 수 있는 "같은 이미지 재저장" 케이스를 보완하기 위해서죠.
물론 처음 의도했던 완전 자동화와는 거리가 있습니다. 그래도 셀러가 등록 전에 스스로 반려 가능성을 확인할 수 있는 장치를 만든 덕분에, 커머스팀의 병목을 조금이나마 덜어낼 수 있었다는 점은 만족스러웠습니다.
가장 이상적인 방향은 상품 업로드 후 이 로직을 람다 같은 서버 환경에서 돌려 검수를 완전히 자동화하는 것이었을 겁니다. 다만 비용과 회사 상황 때문에 여기서 멈춰야 했고, 그 아쉬움은 오래 남았습니다. 비슷한 병목을 안고 있다면, AI를 붙이기 전에 브라우저에서 픽셀 몇 개를 세어보는 것부터 시작해봐도 좋겠습니다.