bg cleaner add

This commit is contained in:
ysj7705 committed 2026-07-23 11:28:43 +09:00
1 parent 18a19acf55
commit 309193a3a7
9 files changed
+1037 -11

No files matched your search

@@ -0,0 +1,254 @@
export type BgRemoveMode = 'threshold' | 'flood';
export interface BgRemoveOptions {
/** 0–100: how far from sampled background still counts as background */
tolerance: number;
/** 0–100: soft edge width beyond hard threshold */
feather: number;
mode: BgRemoveMode;
}
export interface Rgb {
r: number;
g: number;
b: number;
}
export const DEFAULT_BG_REMOVE_OPTIONS: BgRemoveOptions = {
tolerance: 28,
feather: 12,
mode: 'threshold',
};
function clampByte(value: number) {
return Math.min(255, Math.max(0, Math.round(value)));
}
function colorDistance(a: Rgb, r: number, g: number, b: number) {
return Math.max(Math.abs(r - a.r), Math.abs(g - a.g), Math.abs(b - a.b));
}
function median(values: number[]) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2
? sorted[mid]!
: Math.round((sorted[mid - 1]! + sorted[mid]!) / 2);
}
/** Sample corner neighborhoods and take per-channel median as background. */
export function sampleCornerBackground(
data: Uint8ClampedArray,
width: number,
height: number,
radius = 2,
): Rgb {
const rs: number[] = [];
const gs: number[] = [];
const bs: number[] = [];
const corners: Array<[number, number]> = [
[0, 0],
[width - 1, 0],
[0, height - 1],
[width - 1, height - 1],
];
for (const [cx, cy] of corners) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
const x = Math.min(width - 1, Math.max(0, cx + dx));
const y = Math.min(height - 1, Math.max(0, cy + dy));
const i = (y * width + x) * 4;
rs.push(data[i]!);
gs.push(data[i + 1]!);
bs.push(data[i + 2]!);
}
}
}
return { r: median(rs), g: median(gs), b: median(bs) };
}
function hardLimit(tolerance: number) {
return Math.round((Math.min(100, Math.max(0, tolerance)) / 100) * 180);
}
function featherWidth(feather: number) {
return Math.round((Math.min(100, Math.max(0, feather)) / 100) * 80);
}
function alphaForDistance(
distance: number,
hard: number,
soft: number,
originalAlpha: number,
) {
if (distance <= hard) return 0;
if (soft <= 0 || distance >= hard + soft) return originalAlpha;
const t = (distance - hard) / soft;
return clampByte(originalAlpha * t);
}
function loadImage(source: string | Blob | HTMLImageElement | HTMLCanvasElement) {
if (source instanceof HTMLCanvasElement) {
return Promise.resolve(source);
}
if (source instanceof HTMLImageElement) {
if (source.complete && source.naturalWidth) return Promise.resolve(source);
return new Promise<HTMLImageElement>((resolve, reject) => {
source.onload = () => resolve(source);
source.onerror = () =>
reject(new Error('이미지를 불러오지 못했습니다.'));
});
}
const objectUrl =
typeof source === 'string' ? null : URL.createObjectURL(source);
const src = typeof source === 'string' ? source : objectUrl!;
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(image);
};
image.onerror = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
reject(new Error('이미지를 불러오지 못했습니다.'));
};
image.src = src;
});
}
function drawToCanvas(
image: HTMLImageElement | HTMLCanvasElement,
): HTMLCanvasElement {
if (image instanceof HTMLCanvasElement) {
const copy = document.createElement('canvas');
copy.width = image.width;
copy.height = image.height;
const ctx = copy.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0);
return copy;
}
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0);
return canvas;
}
function applyThreshold(
data: Uint8ClampedArray,
bg: Rgb,
hard: number,
soft: number,
) {
for (let i = 0; i < data.length; i += 4) {
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
}
}
function applyFlood(
data: Uint8ClampedArray,
width: number,
height: number,
bg: Rgb,
hard: number,
soft: number,
) {
const total = width * height;
const match = new Uint8Array(total);
const limit = hard + soft;
for (let p = 0; p < total; p += 1) {
const i = p * 4;
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
if (d <= limit) match[p] = 1;
}
const visited = new Uint8Array(total);
const queue = new Int32Array(total);
let head = 0;
let tail = 0;
const seed = (x: number, y: number) => {
const p = y * width + x;
if (!match[p] || visited[p]) return;
visited[p] = 1;
queue[tail++] = p;
};
seed(0, 0);
seed(width - 1, 0);
seed(0, height - 1);
seed(width - 1, height - 1);
while (head < tail) {
const p = queue[head++]!;
const x = p % width;
const y = (p / width) | 0;
if (x > 0) seed(x - 1, y);
if (x + 1 < width) seed(x + 1, y);
if (y > 0) seed(x, y - 1);
if (y + 1 < height) seed(x, y + 1);
}
for (let p = 0; p < total; p += 1) {
if (!visited[p]) continue;
const i = p * 4;
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
}
}
export async function removeBackgroundBySample(
source: string | Blob | HTMLImageElement | HTMLCanvasElement,
options: Partial<BgRemoveOptions> = {},
): Promise<{
blob: Blob;
url: string;
width: number;
height: number;
background: Rgb;
}> {
const opts = { ...DEFAULT_BG_REMOVE_OPTIONS, ...options };
const image = await loadImage(source);
const canvas = drawToCanvas(image);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { data, width, height } = imageData;
const background = sampleCornerBackground(data, width, height);
const hard = hardLimit(opts.tolerance);
const soft = featherWidth(opts.feather);
if (opts.mode === 'flood') {
applyFlood(data, width, height, background, hard, soft);
} else {
applyThreshold(data, background, hard, soft);
}
ctx.putImageData(imageData, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(value) =>
value
? resolve(value)
: reject(new Error('투명 PNG를 만들지 못했습니다.')),
'image/png',
);
});
return {
blob,
url: URL.createObjectURL(blob),
width: canvas.width,
height: canvas.height,
background,
};
}